xref: /openbmc/qemu/tests/avocado/acpi-bits.py (revision effd60c8)
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# 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(QemuBaseTest): #pylint: disable=too-many-instance-attributes
130    """
131    ACPI and SMBIOS tests using biosbits.
132
133    :avocado: tags=arch:x86_64
134    :avocado: tags=acpi
135
136    """
137    # in slower systems the test can take as long as 3 minutes to complete.
138    timeout = BITS_TIMEOUT
139
140    def __init__(self, *args, **kwargs):
141        super().__init__(*args, **kwargs)
142        self._vm = None
143        self._workDir = None
144        self._baseDir = None
145
146        # following are some standard configuration constants
147        self._bitsInternalVer = 2020 # gitlab CI does shallow clones of depth 20
148        self._bitsCommitHash = 'c7920d2b' # commit hash must match
149                                          # the artifact tag below
150        self._bitsTag = "qemu-bits-10262023" # this is the latest bits
151                                             # release as of today.
152        self._bitsArtSHA1Hash = 'b22cdfcfc7453875297d06d626f5474ee36a343f'
153        self._bitsArtURL = ("https://gitlab.com/qemu-project/"
154                            "biosbits-bits/-/jobs/artifacts/%s/"
155                            "download?job=qemu-bits-build" %self._bitsTag)
156        self._debugcon_addr = '0x403'
157        self._debugcon_log = 'debugcon-log.txt'
158        logging.basicConfig(level=logging.INFO)
159        self.logger = logging.getLogger('acpi-bits')
160
161    def _print_log(self, log):
162        self.logger.info('\nlogs from biosbits follows:')
163        self.logger.info('==========================================\n')
164        self.logger.info(log)
165        self.logger.info('==========================================\n')
166
167    def copy_bits_config(self):
168        """ copies the bios bits config file into bits.
169        """
170        config_file = 'bits-cfg.txt'
171        bits_config_dir = os.path.join(self._baseDir, 'acpi-bits',
172                                       'bits-config')
173        target_config_dir = os.path.join(self._workDir,
174                                         'bits-%d' %self._bitsInternalVer,
175                                         'boot')
176        self.assertTrue(os.path.exists(bits_config_dir))
177        self.assertTrue(os.path.exists(target_config_dir))
178        self.assertTrue(os.access(os.path.join(bits_config_dir,
179                                               config_file), os.R_OK))
180        shutil.copy2(os.path.join(bits_config_dir, config_file),
181                     target_config_dir)
182        self.logger.info('copied config file %s to %s',
183                         config_file, target_config_dir)
184
185    def copy_test_scripts(self):
186        """copies the python test scripts into bits. """
187
188        bits_test_dir = os.path.join(self._baseDir, 'acpi-bits',
189                                     'bits-tests')
190        target_test_dir = os.path.join(self._workDir,
191                                       'bits-%d' %self._bitsInternalVer,
192                                       'boot', 'python')
193
194        self.assertTrue(os.path.exists(bits_test_dir))
195        self.assertTrue(os.path.exists(target_test_dir))
196
197        for filename in os.listdir(bits_test_dir):
198            if os.path.isfile(os.path.join(bits_test_dir, filename)) and \
199               filename.endswith('.py2'):
200                # all test scripts are named with extension .py2 so that
201                # avocado does not try to load them. These scripts are
202                # written for python 2.7 not python 3 and hence if avocado
203                # loaded them, it would complain about python 3 specific
204                # syntaxes.
205                newfilename = os.path.splitext(filename)[0] + '.py'
206                shutil.copy2(os.path.join(bits_test_dir, filename),
207                             os.path.join(target_test_dir, newfilename))
208                self.logger.info('copied test file %s to %s',
209                                 filename, target_test_dir)
210
211                # now remove the pyc test file if it exists, otherwise the
212                # changes in the python test script won't be executed.
213                testfile_pyc = os.path.splitext(filename)[0] + '.pyc'
214                if os.access(os.path.join(target_test_dir, testfile_pyc),
215                             os.F_OK):
216                    os.remove(os.path.join(target_test_dir, testfile_pyc))
217                    self.logger.info('removed compiled file %s',
218                                     os.path.join(target_test_dir,
219                                     testfile_pyc))
220
221    def fix_mkrescue(self, mkrescue):
222        """ grub-mkrescue is a bash script with two variables, 'prefix' and
223            'libdir'. They must be pointed to the right location so that the
224            iso can be generated appropriately. We point the two variables to
225            the directory where we have extracted our pre-built bits grub
226            tarball.
227        """
228        grub_x86_64_mods = os.path.join(self._workDir, 'grub-inst-x86_64-efi')
229        grub_i386_mods = os.path.join(self._workDir, 'grub-inst')
230
231        self.assertTrue(os.path.exists(grub_x86_64_mods))
232        self.assertTrue(os.path.exists(grub_i386_mods))
233
234        new_script = ""
235        with open(mkrescue, 'r', encoding='utf-8') as filehandle:
236            orig_script = filehandle.read()
237            new_script = re.sub('(^prefix=)(.*)',
238                                r'\1"%s"' %grub_x86_64_mods,
239                                orig_script, flags=re.M)
240            new_script = re.sub('(^libdir=)(.*)', r'\1"%s/lib"' %grub_i386_mods,
241                                new_script, flags=re.M)
242
243        with open(mkrescue, 'w', encoding='utf-8') as filehandle:
244            filehandle.write(new_script)
245
246    def generate_bits_iso(self):
247        """ Uses grub-mkrescue to generate a fresh bits iso with the python
248            test scripts
249        """
250        bits_dir = os.path.join(self._workDir,
251                                'bits-%d' %self._bitsInternalVer)
252        iso_file = os.path.join(self._workDir,
253                                'bits-%d.iso' %self._bitsInternalVer)
254        mkrescue_script = os.path.join(self._workDir,
255                                       'grub-inst-x86_64-efi', 'bin',
256                                       'grub-mkrescue')
257
258        self.assertTrue(os.access(mkrescue_script,
259                                  os.R_OK | os.W_OK | os.X_OK))
260
261        self.fix_mkrescue(mkrescue_script)
262
263        self.logger.info('using grub-mkrescue for generating biosbits iso ...')
264
265        try:
266            if os.getenv('V') or os.getenv('BITS_DEBUG'):
267                subprocess.check_call([mkrescue_script, '-o', iso_file,
268                                       bits_dir], stderr=subprocess.STDOUT)
269            else:
270                subprocess.check_call([mkrescue_script, '-o',
271                                      iso_file, bits_dir],
272                                      stderr=subprocess.DEVNULL,
273                                      stdout=subprocess.DEVNULL)
274        except Exception as e: # pylint: disable=broad-except
275            self.skipTest("Error while generating the bits iso. "
276                          "Pass V=1 in the environment to get more details. "
277                          + str(e))
278
279        self.assertTrue(os.access(iso_file, os.R_OK))
280
281        self.logger.info('iso file %s successfully generated.', iso_file)
282
283    def setUp(self): # pylint: disable=arguments-differ
284        super().setUp('qemu-system-')
285
286        self._baseDir = os.getenv('AVOCADO_TEST_BASEDIR')
287
288        # workdir could also be avocado's own workdir in self.workdir.
289        # At present, I prefer to maintain my own temporary working
290        # directory. It gives us more control over the generated bits
291        # log files and also for debugging, we may chose not to remove
292        # this working directory so that the logs and iso can be
293        # inspected manually and archived if needed.
294        self._workDir = tempfile.mkdtemp(prefix='acpi-bits-',
295                                         suffix='.tmp')
296        self.logger.info('working dir: %s', self._workDir)
297
298        prebuiltDir = os.path.join(self._workDir, 'prebuilt')
299        if not os.path.isdir(prebuiltDir):
300            os.mkdir(prebuiltDir, mode=0o775)
301
302        bits_zip_file = os.path.join(prebuiltDir, 'bits-%d-%s.zip'
303                                     %(self._bitsInternalVer,
304                                       self._bitsCommitHash))
305        grub_tar_file = os.path.join(prebuiltDir,
306                                     'bits-%d-%s-grub.tar.gz'
307                                     %(self._bitsInternalVer,
308                                       self._bitsCommitHash))
309
310        bitsLocalArtLoc = self.fetch_asset(self._bitsArtURL,
311                                           asset_hash=self._bitsArtSHA1Hash)
312        self.logger.info("downloaded bits artifacts to %s", bitsLocalArtLoc)
313
314        # extract the bits artifact in the temp working directory
315        with zipfile.ZipFile(bitsLocalArtLoc, 'r') as zref:
316            zref.extractall(prebuiltDir)
317
318        # extract the bits software in the temp working directory
319        with zipfile.ZipFile(bits_zip_file, 'r') as zref:
320            zref.extractall(self._workDir)
321
322        with tarfile.open(grub_tar_file, 'r', encoding='utf-8') as tarball:
323            tarball.extractall(self._workDir)
324
325        self.copy_test_scripts()
326        self.copy_bits_config()
327        self.generate_bits_iso()
328
329    def parse_log(self):
330        """parse the log generated by running bits tests and
331           check for failures.
332        """
333        debugconf = os.path.join(self._workDir, self._debugcon_log)
334        log = ""
335        with open(debugconf, 'r', encoding='utf-8') as filehandle:
336            log = filehandle.read()
337
338        matchiter = re.finditer(r'(.*Summary: )(\d+ passed), (\d+ failed).*',
339                                log)
340        for match in matchiter:
341            # verify that no test cases failed.
342            try:
343                self.assertEqual(match.group(3).split()[0], '0',
344                                 'Some bits tests seems to have failed. ' \
345                                 'Please check the test logs for more info.')
346            except AssertionError as e:
347                self._print_log(log)
348                raise e
349            else:
350                if os.getenv('V') or os.getenv('BITS_DEBUG'):
351                    self._print_log(log)
352
353    def tearDown(self):
354        """
355           Lets do some cleanups.
356        """
357        if self._vm:
358            self.assertFalse(not self._vm.is_running)
359        if not os.getenv('BITS_DEBUG') and self._workDir:
360            self.logger.info('removing the work directory %s', self._workDir)
361            shutil.rmtree(self._workDir)
362        else:
363            self.logger.info('not removing the work directory %s ' \
364                             'as BITS_DEBUG is ' \
365                             'passed in the environment', self._workDir)
366        super().tearDown()
367
368    def test_acpi_smbios_bits(self):
369        """The main test case implementation."""
370
371        iso_file = os.path.join(self._workDir,
372                                'bits-%d.iso' %self._bitsInternalVer)
373
374        self.assertTrue(os.access(iso_file, os.R_OK))
375
376        self._vm = QEMUBitsMachine(binary=self.qemu_bin,
377                                   base_temp_dir=self._workDir,
378                                   debugcon_log=self._debugcon_log,
379                                   debugcon_addr=self._debugcon_addr)
380
381        self._vm.add_args('-cdrom', '%s' %iso_file)
382        # the vm needs to be run under icount so that TCG emulation is
383        # consistent in terms of timing. smilatency tests have consistent
384        # timing requirements.
385        self._vm.add_args('-icount', 'auto')
386        # currently there is no support in bits for recognizing 64-bit SMBIOS
387        # entry points. QEMU defaults to 64-bit entry points since the
388        # upstream commit bf376f3020 ("hw/i386/pc: Default to use SMBIOS 3.0
389        # for newer machine models"). Therefore, enforce 32-bit entry point.
390        self._vm.add_args('-machine', 'smbios-entry-point-type=32')
391
392        # enable console logging
393        self._vm.set_console()
394        self._vm.launch()
395
396        self.logger.debug("Console output from bits VM follows ...")
397        c_drainer = drainer.LineLogger(self._vm.console_socket.fileno(),
398                                       logger=self.logger.getChild("console"),
399                                       stop_check=(lambda :
400                                                   not self._vm.is_running()))
401        c_drainer.start()
402
403        # biosbits has been configured to run all the specified test suites
404        # in batch mode and then automatically initiate a vm shutdown.
405        # Set timeout to BITS_TIMEOUT for SHUTDOWN event from bits VM at par
406        # with the avocado test timeout.
407        self._vm.event_wait('SHUTDOWN', timeout=BITS_TIMEOUT)
408        self._vm.wait(timeout=None)
409        self.parse_log()
410