xref: /qemu/tests/avocado/acpi-bits.py (revision ec6f3fc3)
1#!/usr/bin/env python3
2# group: rw quick
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 avocado 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 time
43import zipfile
44from typing import (
45    List,
46    Optional,
47    Sequence,
48)
49from qemu.machine import QEMUMachine
50from avocado import skipIf
51from avocado.utils import datadrainer as drainer
52from avocado_qemu import QemuBaseTest
53
54deps = ["xorriso", "mformat"] # dependent tools needed in the test setup/box.
55supported_platforms = ['x86_64'] # supported test platforms.
56
57
58def which(tool):
59    """ looks up the full path for @tool, returns None if not found
60        or if @tool does not have executable permissions.
61    """
62    paths=os.getenv('PATH')
63    for p in paths.split(os.path.pathsep):
64        p = os.path.join(p, tool)
65        if os.path.exists(p) and os.access(p, os.X_OK):
66            return p
67    return None
68
69def missing_deps():
70    """ returns True if any of the test dependent tools are absent.
71    """
72    for dep in deps:
73        if which(dep) is None:
74            return True
75    return False
76
77def supported_platform():
78    """ checks if the test is running on a supported platform.
79    """
80    return platform.machine() in supported_platforms
81
82class QEMUBitsMachine(QEMUMachine): # pylint: disable=too-few-public-methods
83    """
84    A QEMU VM, with isa-debugcon enabled and bits iso passed
85    using -cdrom to QEMU commandline.
86
87    """
88    def __init__(self,
89                 binary: str,
90                 args: Sequence[str] = (),
91                 wrapper: Sequence[str] = (),
92                 name: Optional[str] = None,
93                 base_temp_dir: str = "/var/tmp",
94                 debugcon_log: str = "debugcon-log.txt",
95                 debugcon_addr: str = "0x403",
96                 qmp_timer: Optional[float] = None):
97        # pylint: disable=too-many-arguments
98
99        if name is None:
100            name = "qemu-bits-%d" % os.getpid()
101        super().__init__(binary, args, wrapper=wrapper, name=name,
102                         base_temp_dir=base_temp_dir,
103                         qmp_timer=qmp_timer)
104        self.debugcon_log = debugcon_log
105        self.debugcon_addr = debugcon_addr
106        self.base_temp_dir = base_temp_dir
107
108    @property
109    def _base_args(self) -> List[str]:
110        args = super()._base_args
111        args.extend([
112            '-chardev',
113            'file,path=%s,id=debugcon' %os.path.join(self.base_temp_dir,
114                                                     self.debugcon_log),
115            '-device',
116            'isa-debugcon,iobase=%s,chardev=debugcon' %self.debugcon_addr,
117        ])
118        return args
119
120    def base_args(self):
121        """return the base argument to QEMU binary"""
122        return self._base_args
123
124@skipIf(not supported_platform() or missing_deps(),
125        'unsupported platform or dependencies (%s) not installed' \
126        % ','.join(deps))
127class AcpiBitsTest(QemuBaseTest): #pylint: disable=too-many-instance-attributes
128    """
129    ACPI and SMBIOS tests using biosbits.
130
131    :avocado: tags=arch:x86_64
132    :avocado: tags=acpi
133
134    """
135    # in slower systems the test can take as long as 3 minutes to complete.
136    timeout = 200
137
138    def __init__(self, *args, **kwargs):
139        super().__init__(*args, **kwargs)
140        self._vm = None
141        self._workDir = None
142        self._baseDir = None
143
144        # following are some standard configuration constants
145        self._bitsInternalVer = 2020 # gitlab CI does shallow clones of depth 20
146        self._bitsCommitHash = 'c7920d2b' # commit hash must match
147                                          # the artifact tag below
148        self._bitsTag = "qemu-bits-10262023" # this is the latest bits
149                                             # release as of today.
150        self._bitsArtSHA1Hash = 'b22cdfcfc7453875297d06d626f5474ee36a343f'
151        self._bitsArtURL = ("https://gitlab.com/qemu-project/"
152                            "biosbits-bits/-/jobs/artifacts/%s/"
153                            "download?job=qemu-bits-build" %self._bitsTag)
154        self._debugcon_addr = '0x403'
155        self._debugcon_log = 'debugcon-log.txt'
156        logging.basicConfig(level=logging.INFO)
157        self.logger = logging.getLogger('acpi-bits')
158
159    def _print_log(self, log):
160        self.logger.info('\nlogs from biosbits follows:')
161        self.logger.info('==========================================\n')
162        self.logger.info(log)
163        self.logger.info('==========================================\n')
164
165    def copy_bits_config(self):
166        """ copies the bios bits config file into bits.
167        """
168        config_file = 'bits-cfg.txt'
169        bits_config_dir = os.path.join(self._baseDir, 'acpi-bits',
170                                       'bits-config')
171        target_config_dir = os.path.join(self._workDir,
172                                         'bits-%d' %self._bitsInternalVer,
173                                         'boot')
174        self.assertTrue(os.path.exists(bits_config_dir))
175        self.assertTrue(os.path.exists(target_config_dir))
176        self.assertTrue(os.access(os.path.join(bits_config_dir,
177                                               config_file), os.R_OK))
178        shutil.copy2(os.path.join(bits_config_dir, config_file),
179                     target_config_dir)
180        self.logger.info('copied config file %s to %s',
181                         config_file, target_config_dir)
182
183    def copy_test_scripts(self):
184        """copies the python test scripts into bits. """
185
186        bits_test_dir = os.path.join(self._baseDir, 'acpi-bits',
187                                     'bits-tests')
188        target_test_dir = os.path.join(self._workDir,
189                                       'bits-%d' %self._bitsInternalVer,
190                                       'boot', 'python')
191
192        self.assertTrue(os.path.exists(bits_test_dir))
193        self.assertTrue(os.path.exists(target_test_dir))
194
195        for filename in os.listdir(bits_test_dir):
196            if os.path.isfile(os.path.join(bits_test_dir, filename)) and \
197               filename.endswith('.py2'):
198                # all test scripts are named with extension .py2 so that
199                # avocado does not try to load them. These scripts are
200                # written for python 2.7 not python 3 and hence if avocado
201                # loaded them, it would complain about python 3 specific
202                # syntaxes.
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._bitsInternalVer)
250        iso_file = os.path.join(self._workDir,
251                                'bits-%d.iso' %self._bitsInternalVer)
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                subprocess.check_call([mkrescue_script, '-o', iso_file,
266                                       bits_dir], stderr=subprocess.STDOUT)
267            else:
268                subprocess.check_call([mkrescue_script, '-o',
269                                      iso_file, bits_dir],
270                                      stderr=subprocess.DEVNULL,
271                                      stdout=subprocess.DEVNULL)
272        except Exception as e: # pylint: disable=broad-except
273            self.skipTest("Error while generating the bits iso. "
274                          "Pass V=1 in the environment to get more details. "
275                          + str(e))
276
277        self.assertTrue(os.access(iso_file, os.R_OK))
278
279        self.logger.info('iso file %s successfully generated.', iso_file)
280
281    def setUp(self): # pylint: disable=arguments-differ
282        super().setUp('qemu-system-')
283
284        self._baseDir = os.getenv('AVOCADO_TEST_BASEDIR')
285
286        # workdir could also be avocado's own workdir in self.workdir.
287        # At present, I prefer to maintain my own temporary working
288        # directory. It gives us more control over the generated bits
289        # log files and also for debugging, we may chose not to remove
290        # this working directory so that the logs and iso can be
291        # inspected manually and archived if needed.
292        self._workDir = tempfile.mkdtemp(prefix='acpi-bits-',
293                                         suffix='.tmp')
294        self.logger.info('working dir: %s', self._workDir)
295
296        prebuiltDir = os.path.join(self._workDir, 'prebuilt')
297        if not os.path.isdir(prebuiltDir):
298            os.mkdir(prebuiltDir, mode=0o775)
299
300        bits_zip_file = os.path.join(prebuiltDir, 'bits-%d-%s.zip'
301                                     %(self._bitsInternalVer,
302                                       self._bitsCommitHash))
303        grub_tar_file = os.path.join(prebuiltDir,
304                                     'bits-%d-%s-grub.tar.gz'
305                                     %(self._bitsInternalVer,
306                                       self._bitsCommitHash))
307
308        bitsLocalArtLoc = self.fetch_asset(self._bitsArtURL,
309                                           asset_hash=self._bitsArtSHA1Hash)
310        self.logger.info("downloaded bits artifacts to %s", bitsLocalArtLoc)
311
312        # extract the bits artifact in the temp working directory
313        with zipfile.ZipFile(bitsLocalArtLoc, 'r') as zref:
314            zref.extractall(prebuiltDir)
315
316        # extract the bits software in the temp working directory
317        with zipfile.ZipFile(bits_zip_file, 'r') as zref:
318            zref.extractall(self._workDir)
319
320        with tarfile.open(grub_tar_file, 'r', encoding='utf-8') as tarball:
321            tarball.extractall(self._workDir)
322
323        self.copy_test_scripts()
324        self.copy_bits_config()
325        self.generate_bits_iso()
326
327    def parse_log(self):
328        """parse the log generated by running bits tests and
329           check for failures.
330        """
331        debugconf = os.path.join(self._workDir, self._debugcon_log)
332        log = ""
333        with open(debugconf, 'r', encoding='utf-8') as filehandle:
334            log = filehandle.read()
335
336        matchiter = re.finditer(r'(.*Summary: )(\d+ passed), (\d+ failed).*',
337                                log)
338        for match in matchiter:
339            # verify that no test cases failed.
340            try:
341                self.assertEqual(match.group(3).split()[0], '0',
342                                 'Some bits tests seems to have failed. ' \
343                                 'Please check the test logs for more info.')
344            except AssertionError as e:
345                self._print_log(log)
346                raise e
347            else:
348                if os.getenv('V') or os.getenv('BITS_DEBUG'):
349                    self._print_log(log)
350
351    def tearDown(self):
352        """
353           Lets do some cleanups.
354        """
355        if self._vm:
356            self.assertFalse(not self._vm.is_running)
357        if not os.getenv('BITS_DEBUG') and self._workDir:
358            self.logger.info('removing the work directory %s', self._workDir)
359            shutil.rmtree(self._workDir)
360        else:
361            self.logger.info('not removing the work directory %s ' \
362                             'as BITS_DEBUG is ' \
363                             'passed in the environment', self._workDir)
364        super().tearDown()
365
366    def test_acpi_smbios_bits(self):
367        """The main test case implementation."""
368
369        iso_file = os.path.join(self._workDir,
370                                'bits-%d.iso' %self._bitsInternalVer)
371
372        self.assertTrue(os.access(iso_file, os.R_OK))
373
374        self._vm = QEMUBitsMachine(binary=self.qemu_bin,
375                                   base_temp_dir=self._workDir,
376                                   debugcon_log=self._debugcon_log,
377                                   debugcon_addr=self._debugcon_addr)
378
379        self._vm.add_args('-cdrom', '%s' %iso_file)
380        # the vm needs to be run under icount so that TCG emulation is
381        # consistent in terms of timing. smilatency tests have consistent
382        # timing requirements.
383        self._vm.add_args('-icount', 'auto')
384        # currently there is no support in bits for recognizing 64-bit SMBIOS
385        # entry points. QEMU defaults to 64-bit entry points since the
386        # upstream commit bf376f3020 ("hw/i386/pc: Default to use SMBIOS 3.0
387        # for newer machine models"). Therefore, enforce 32-bit entry point.
388        self._vm.add_args('-machine', 'smbios-entry-point-type=32')
389
390        # enable console logging
391        self._vm.set_console()
392        self._vm.launch()
393
394        self.logger.debug("Console output from bits VM follows ...")
395        c_drainer = drainer.LineLogger(self._vm.console_socket.fileno(),
396                                       logger=self.logger.getChild("console"),
397                                       stop_check=(lambda :
398                                                   not self._vm.is_running()))
399        c_drainer.start()
400
401        # biosbits has been configured to run all the specified test suites
402        # in batch mode and then automatically initiate a vm shutdown.
403        # Rely on avocado's unit test timeout.
404        self._vm.event_wait('SHUTDOWN')
405        self._vm.wait(timeout=None)
406        self.parse_log()
407