xref: /qemu/tests/functional/test_acpi_bits.py (revision fd363a14f68a7bcbede024fb5155371c19b8f5d2)
1#!/usr/bin/env python3
2#
3# Exercise QEMU generated ACPI/SMBIOS tables using biosbits,
4# https://biosbits.org/
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program.  If not, see <http://www.gnu.org/licenses/>.
18#
19#
20# Author:
21#  Ani Sinha <anisinha@redhat.com>
22
23# pylint: disable=invalid-name
24# pylint: disable=consider-using-f-string
25
26"""
27This is QEMU ACPI/SMBIOS functional tests using biosbits.
28Biosbits is available originally at https://biosbits.org/.
29This test uses a fork of the upstream bits and has numerous fixes
30including an upgraded acpica. The fork is located here:
31https://gitlab.com/qemu-project/biosbits-bits .
32"""
33
34import logging
35import os
36import platform
37import re
38import shutil
39import subprocess
40import tarfile
41import tempfile
42import zipfile
43
44from pathlib import Path
45from typing import (
46    List,
47    Optional,
48    Sequence,
49)
50from qemu.machine import QEMUMachine
51from unittest import skipIf
52from qemu_test import QemuSystemTest, Asset
53
54deps = ["xorriso", "mformat"] # dependent tools needed in the test setup/box.
55supported_platforms = ['x86_64'] # supported test platforms.
56
57# default timeout of 120 secs is sometimes not enough for bits test.
58BITS_TIMEOUT = 200
59
60def which(tool):
61    """ looks up the full path for @tool, returns None if not found
62        or if @tool does not have executable permissions.
63    """
64    paths=os.getenv('PATH')
65    for p in paths.split(os.path.pathsep):
66        p = os.path.join(p, tool)
67        if os.path.exists(p) and os.access(p, os.X_OK):
68            return p
69    return None
70
71def missing_deps():
72    """ returns True if any of the test dependent tools are absent.
73    """
74    for dep in deps:
75        if which(dep) is None:
76            return True
77    return False
78
79def supported_platform():
80    """ checks if the test is running on a supported platform.
81    """
82    return platform.machine() in supported_platforms
83
84class QEMUBitsMachine(QEMUMachine): # pylint: disable=too-few-public-methods
85    """
86    A QEMU VM, with isa-debugcon enabled and bits iso passed
87    using -cdrom to QEMU commandline.
88
89    """
90    def __init__(self,
91                 binary: str,
92                 args: Sequence[str] = (),
93                 wrapper: Sequence[str] = (),
94                 name: Optional[str] = None,
95                 base_temp_dir: str = "/var/tmp",
96                 debugcon_log: str = "debugcon-log.txt",
97                 debugcon_addr: str = "0x403",
98                 qmp_timer: Optional[float] = None):
99        # pylint: disable=too-many-arguments
100
101        if name is None:
102            name = "qemu-bits-%d" % os.getpid()
103        super().__init__(binary, args, wrapper=wrapper, name=name,
104                         base_temp_dir=base_temp_dir,
105                         qmp_timer=qmp_timer)
106        self.debugcon_log = debugcon_log
107        self.debugcon_addr = debugcon_addr
108        self.base_temp_dir = base_temp_dir
109
110    @property
111    def _base_args(self) -> List[str]:
112        args = super()._base_args
113        args.extend([
114            '-chardev',
115            'file,path=%s,id=debugcon' %os.path.join(self.base_temp_dir,
116                                                     self.debugcon_log),
117            '-device',
118            'isa-debugcon,iobase=%s,chardev=debugcon' %self.debugcon_addr,
119        ])
120        return args
121
122    def base_args(self):
123        """return the base argument to QEMU binary"""
124        return self._base_args
125
126@skipIf(not supported_platform() or missing_deps(),
127        'unsupported platform or dependencies (%s) not installed' \
128        % ','.join(deps))
129class AcpiBitsTest(QemuSystemTest): #pylint: disable=too-many-instance-attributes
130    """
131    ACPI and SMBIOS tests using biosbits.
132    """
133    # in slower systems the test can take as long as 3 minutes to complete.
134    timeout = BITS_TIMEOUT
135
136    # following are some standard configuration constants
137    # gitlab CI does shallow clones of depth 20
138    BITS_INTERNAL_VER = 2020
139    # commit hash must match the artifact tag below
140    BITS_COMMIT_HASH = 'c7920d2b'
141    # this is the latest bits release as of today.
142    BITS_TAG = "qemu-bits-10262023"
143
144    ASSET_BITS = Asset(("https://gitlab.com/qemu-project/"
145                        "biosbits-bits/-/jobs/artifacts/%s/"
146                        "download?job=qemu-bits-build" % BITS_TAG),
147                       '1b8dd612c6831a6b491716a77acc486666aaa867051cdc34f7ce169c2e25f487')
148
149    def __init__(self, *args, **kwargs):
150        super().__init__(*args, **kwargs)
151        self._vm = None
152        self._baseDir = None
153
154        self._debugcon_addr = '0x403'
155        self._debugcon_log = 'debugcon-log.txt'
156        self.logger = self.log
157
158    def _print_log(self, log):
159        self.logger.info('\nlogs from biosbits follows:')
160        self.logger.info('==========================================\n')
161        self.logger.info(log)
162        self.logger.info('==========================================\n')
163
164    def copy_bits_config(self):
165        """ copies the bios bits config file into bits.
166        """
167        config_file = 'bits-cfg.txt'
168        bits_config_dir = os.path.join(self._baseDir, 'acpi-bits',
169                                       'bits-config')
170        target_config_dir = os.path.join(self.workdir,
171                                         'bits-%d' %self.BITS_INTERNAL_VER,
172                                         'boot')
173        self.assertTrue(os.path.exists(bits_config_dir))
174        self.assertTrue(os.path.exists(target_config_dir))
175        self.assertTrue(os.access(os.path.join(bits_config_dir,
176                                               config_file), os.R_OK))
177        shutil.copy2(os.path.join(bits_config_dir, config_file),
178                     target_config_dir)
179        self.logger.info('copied config file %s to %s',
180                         config_file, target_config_dir)
181
182    def copy_test_scripts(self):
183        """copies the python test scripts into bits. """
184
185        bits_test_dir = os.path.join(self._baseDir, 'acpi-bits',
186                                     'bits-tests')
187        target_test_dir = os.path.join(self.workdir,
188                                       'bits-%d' %self.BITS_INTERNAL_VER,
189                                       'boot', 'python')
190
191        self.assertTrue(os.path.exists(bits_test_dir))
192        self.assertTrue(os.path.exists(target_test_dir))
193
194        for filename in os.listdir(bits_test_dir):
195            if os.path.isfile(os.path.join(bits_test_dir, filename)) and \
196               filename.endswith('.py2'):
197                # All test scripts are named with extension .py2 so that
198                # they are not run by accident.
199                #
200                # These scripts are intended to run inside the test VM
201                # and are written for python 2.7 not python 3, hence
202                # would cause syntax errors if loaded ouside the VM.
203                newfilename = os.path.splitext(filename)[0] + '.py'
204                shutil.copy2(os.path.join(bits_test_dir, filename),
205                             os.path.join(target_test_dir, newfilename))
206                self.logger.info('copied test file %s to %s',
207                                 filename, target_test_dir)
208
209                # now remove the pyc test file if it exists, otherwise the
210                # changes in the python test script won't be executed.
211                testfile_pyc = os.path.splitext(filename)[0] + '.pyc'
212                if os.access(os.path.join(target_test_dir, testfile_pyc),
213                             os.F_OK):
214                    os.remove(os.path.join(target_test_dir, testfile_pyc))
215                    self.logger.info('removed compiled file %s',
216                                     os.path.join(target_test_dir,
217                                     testfile_pyc))
218
219    def fix_mkrescue(self, mkrescue):
220        """ grub-mkrescue is a bash script with two variables, 'prefix' and
221            'libdir'. They must be pointed to the right location so that the
222            iso can be generated appropriately. We point the two variables to
223            the directory where we have extracted our pre-built bits grub
224            tarball.
225        """
226        grub_x86_64_mods = os.path.join(self.workdir, 'grub-inst-x86_64-efi')
227        grub_i386_mods = os.path.join(self.workdir, 'grub-inst')
228
229        self.assertTrue(os.path.exists(grub_x86_64_mods))
230        self.assertTrue(os.path.exists(grub_i386_mods))
231
232        new_script = ""
233        with open(mkrescue, 'r', encoding='utf-8') as filehandle:
234            orig_script = filehandle.read()
235            new_script = re.sub('(^prefix=)(.*)',
236                                r'\1"%s"' %grub_x86_64_mods,
237                                orig_script, flags=re.M)
238            new_script = re.sub('(^libdir=)(.*)', r'\1"%s/lib"' %grub_i386_mods,
239                                new_script, flags=re.M)
240
241        with open(mkrescue, 'w', encoding='utf-8') as filehandle:
242            filehandle.write(new_script)
243
244    def generate_bits_iso(self):
245        """ Uses grub-mkrescue to generate a fresh bits iso with the python
246            test scripts
247        """
248        bits_dir = os.path.join(self.workdir,
249                                'bits-%d' %self.BITS_INTERNAL_VER)
250        iso_file = os.path.join(self.workdir,
251                                'bits-%d.iso' %self.BITS_INTERNAL_VER)
252        mkrescue_script = os.path.join(self.workdir,
253                                       'grub-inst-x86_64-efi', 'bin',
254                                       'grub-mkrescue')
255
256        self.assertTrue(os.access(mkrescue_script,
257                                  os.R_OK | os.W_OK | os.X_OK))
258
259        self.fix_mkrescue(mkrescue_script)
260
261        self.logger.info('using grub-mkrescue for generating biosbits iso ...')
262
263        try:
264            if os.getenv('V') or os.getenv('BITS_DEBUG'):
265                proc = subprocess.run([mkrescue_script, '-o', iso_file,
266                                       bits_dir],
267                                      stdout=subprocess.PIPE,
268                                      stderr=subprocess.STDOUT,
269                                      check=True)
270                self.logger.info("grub-mkrescue output %s" % proc.stdout)
271            else:
272                subprocess.check_call([mkrescue_script, '-o',
273                                      iso_file, bits_dir],
274                                      stderr=subprocess.DEVNULL,
275                                      stdout=subprocess.DEVNULL)
276        except Exception as e: # pylint: disable=broad-except
277            self.skipTest("Error while generating the bits iso. "
278                          "Pass V=1 in the environment to get more details. "
279                          + str(e))
280
281        self.assertTrue(os.access(iso_file, os.R_OK))
282
283        self.logger.info('iso file %s successfully generated.', iso_file)
284
285    def setUp(self): # pylint: disable=arguments-differ
286        super().setUp()
287        self.logger = self.log
288
289        self._baseDir = Path(__file__).parent
290
291        prebuiltDir = os.path.join(self.workdir, 'prebuilt')
292        if not os.path.isdir(prebuiltDir):
293            os.mkdir(prebuiltDir, mode=0o775)
294
295        bits_zip_file = os.path.join(prebuiltDir, 'bits-%d-%s.zip'
296                                     %(self.BITS_INTERNAL_VER,
297                                       self.BITS_COMMIT_HASH))
298        grub_tar_file = os.path.join(prebuiltDir,
299                                     'bits-%d-%s-grub.tar.gz'
300                                     %(self.BITS_INTERNAL_VER,
301                                       self.BITS_COMMIT_HASH))
302
303        bitsLocalArtLoc = self.ASSET_BITS.fetch()
304        self.logger.info("downloaded bits artifacts to %s", bitsLocalArtLoc)
305
306        # extract the bits artifact in the temp working directory
307        with zipfile.ZipFile(bitsLocalArtLoc, 'r') as zref:
308            zref.extractall(prebuiltDir)
309
310        # extract the bits software in the temp working directory
311        with zipfile.ZipFile(bits_zip_file, 'r') as zref:
312            zref.extractall(self.workdir)
313
314        with tarfile.open(grub_tar_file, 'r', encoding='utf-8') as tarball:
315            tarball.extractall(self.workdir)
316
317        self.copy_test_scripts()
318        self.copy_bits_config()
319        self.generate_bits_iso()
320
321    def parse_log(self):
322        """parse the log generated by running bits tests and
323           check for failures.
324        """
325        debugconf = os.path.join(self.workdir, self._debugcon_log)
326        log = ""
327        with open(debugconf, 'r', encoding='utf-8') as filehandle:
328            log = filehandle.read()
329
330        matchiter = re.finditer(r'(.*Summary: )(\d+ passed), (\d+ failed).*',
331                                log)
332        for match in matchiter:
333            # verify that no test cases failed.
334            try:
335                self.assertEqual(match.group(3).split()[0], '0',
336                                 'Some bits tests seems to have failed. ' \
337                                 'Please check the test logs for more info.')
338            except AssertionError as e:
339                self._print_log(log)
340                raise e
341            else:
342                if os.getenv('V') or os.getenv('BITS_DEBUG'):
343                    self._print_log(log)
344
345    def tearDown(self):
346        """
347           Lets do some cleanups.
348        """
349        if self._vm:
350            self.assertFalse(not self._vm.is_running)
351        super().tearDown()
352
353    def test_acpi_smbios_bits(self):
354        """The main test case implementation."""
355
356        self.set_machine('pc')
357        iso_file = os.path.join(self.workdir,
358                                'bits-%d.iso' %self.BITS_INTERNAL_VER)
359
360        self.assertTrue(os.access(iso_file, os.R_OK))
361
362        self._vm = QEMUBitsMachine(binary=self.qemu_bin,
363                                   base_temp_dir=self.workdir,
364                                   debugcon_log=self._debugcon_log,
365                                   debugcon_addr=self._debugcon_addr)
366
367        self._vm.add_args('-cdrom', '%s' %iso_file)
368        # the vm needs to be run under icount so that TCG emulation is
369        # consistent in terms of timing. smilatency tests have consistent
370        # timing requirements.
371        self._vm.add_args('-icount', 'auto')
372        # currently there is no support in bits for recognizing 64-bit SMBIOS
373        # entry points. QEMU defaults to 64-bit entry points since the
374        # upstream commit bf376f3020 ("hw/i386/pc: Default to use SMBIOS 3.0
375        # for newer machine models"). Therefore, enforce 32-bit entry point.
376        self._vm.add_args('-machine', 'smbios-entry-point-type=32')
377
378        # enable console logging
379        self._vm.set_console()
380        self._vm.launch()
381
382
383        # biosbits has been configured to run all the specified test suites
384        # in batch mode and then automatically initiate a vm shutdown.
385        self._vm.event_wait('SHUTDOWN', timeout=BITS_TIMEOUT)
386        self._vm.wait(timeout=None)
387        self.logger.debug("Checking console output ...")
388        self.parse_log()
389
390if __name__ == '__main__':
391    QemuSystemTest.main()
392