xref: /qemu/tests/avocado/acpi-bits.py (revision b2a3cbb8)
1#!/usr/bin/env python3
2# group: rw quick
3# Exercize 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 <ani@anisinha.ca>
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_qemu import QemuBaseTest
52
53deps = ["xorriso"] # dependent tools needed in the test setup/box.
54supported_platforms = ['x86_64'] # supported test platforms.
55
56
57def which(tool):
58    """ looks up the full path for @tool, returns None if not found
59        or if @tool does not have executable permissions.
60    """
61    paths=os.getenv('PATH')
62    for p in paths.split(os.path.pathsep):
63        p = os.path.join(p, tool)
64        if os.path.exists(p) and os.access(p, os.X_OK):
65            return p
66    return None
67
68def missing_deps():
69    """ returns True if any of the test dependent tools are absent.
70    """
71    for dep in deps:
72        if which(dep) is None:
73            return True
74    return False
75
76def supported_platform():
77    """ checks if the test is running on a supported platform.
78    """
79    return platform.machine() in supported_platforms
80
81class QEMUBitsMachine(QEMUMachine): # pylint: disable=too-few-public-methods
82    """
83    A QEMU VM, with isa-debugcon enabled and bits iso passed
84    using -cdrom to QEMU commandline.
85
86    """
87    def __init__(self,
88                 binary: str,
89                 args: Sequence[str] = (),
90                 wrapper: Sequence[str] = (),
91                 name: Optional[str] = None,
92                 base_temp_dir: str = "/var/tmp",
93                 debugcon_log: str = "debugcon-log.txt",
94                 debugcon_addr: str = "0x403",
95                 sock_dir: Optional[str] = None,
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        if sock_dir is None:
102            sock_dir = base_temp_dir
103        super().__init__(binary, args, wrapper=wrapper, name=name,
104                         base_temp_dir=base_temp_dir,
105                         sock_dir=sock_dir, 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() or os.getenv('GITLAB_CI'),
127        'incorrect platform or dependencies (%s) not installed ' \
128        'or running on GitLab' % ','.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    def __init__(self, *args, **kwargs):
138        super().__init__(*args, **kwargs)
139        self._vm = None
140        self._workDir = None
141        self._baseDir = None
142
143        # following are some standard configuration constants
144        self._bitsInternalVer = 2020
145        self._bitsCommitHash = 'b48b88ff' # commit hash must match
146                                          # the artifact tag below
147        self._bitsTag = "qemu-bits-10182022" # this is the latest bits
148                                             # release as of today.
149        self._bitsArtSHA1Hash = 'b04790ac9b99b5662d0416392c73b97580641fe5'
150        self._bitsArtURL = ("https://gitlab.com/qemu-project/"
151                            "biosbits-bits/-/jobs/artifacts/%s/"
152                            "download?job=qemu-bits-build" %self._bitsTag)
153        self._debugcon_addr = '0x403'
154        self._debugcon_log = 'debugcon-log.txt'
155        logging.basicConfig(level=logging.INFO)
156        self.logger = logging.getLogger('acpi-bits')
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._bitsInternalVer,
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._bitsInternalVer,
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                # avocado does not try to load them. These scripts are
199                # written for python 2.7 not python 3 and hence if avocado
200                # loaded them, it would complain about python 3 specific
201                # syntaxes.
202                newfilename = os.path.splitext(filename)[0] + '.py'
203                shutil.copy2(os.path.join(bits_test_dir, filename),
204                             os.path.join(target_test_dir, newfilename))
205                self.logger.info('copied test file %s to %s',
206                                 filename, target_test_dir)
207
208                # now remove the pyc test file if it exists, otherwise the
209                # changes in the python test script won't be executed.
210                testfile_pyc = os.path.splitext(filename)[0] + '.pyc'
211                if os.access(os.path.join(target_test_dir, testfile_pyc),
212                             os.F_OK):
213                    os.remove(os.path.join(target_test_dir, testfile_pyc))
214                    self.logger.info('removed compiled file %s',
215                                     os.path.join(target_test_dir,
216                                     testfile_pyc))
217
218    def fix_mkrescue(self, mkrescue):
219        """ grub-mkrescue is a bash script with two variables, 'prefix' and
220            'libdir'. They must be pointed to the right location so that the
221            iso can be generated appropriately. We point the two variables to
222            the directory where we have extracted our pre-built bits grub
223            tarball.
224        """
225        grub_x86_64_mods = os.path.join(self._workDir, 'grub-inst-x86_64-efi')
226        grub_i386_mods = os.path.join(self._workDir, 'grub-inst')
227
228        self.assertTrue(os.path.exists(grub_x86_64_mods))
229        self.assertTrue(os.path.exists(grub_i386_mods))
230
231        new_script = ""
232        with open(mkrescue, 'r', encoding='utf-8') as filehandle:
233            orig_script = filehandle.read()
234            new_script = re.sub('(^prefix=)(.*)',
235                                r'\1"%s"' %grub_x86_64_mods,
236                                orig_script, flags=re.M)
237            new_script = re.sub('(^libdir=)(.*)', r'\1"%s/lib"' %grub_i386_mods,
238                                new_script, flags=re.M)
239
240        with open(mkrescue, 'w', encoding='utf-8') as filehandle:
241            filehandle.write(new_script)
242
243    def generate_bits_iso(self):
244        """ Uses grub-mkrescue to generate a fresh bits iso with the python
245            test scripts
246        """
247        bits_dir = os.path.join(self._workDir,
248                                'bits-%d' %self._bitsInternalVer)
249        iso_file = os.path.join(self._workDir,
250                                'bits-%d.iso' %self._bitsInternalVer)
251        mkrescue_script = os.path.join(self._workDir,
252                                       'grub-inst-x86_64-efi', 'bin',
253                                       'grub-mkrescue')
254
255        self.assertTrue(os.access(mkrescue_script,
256                                  os.R_OK | os.W_OK | os.X_OK))
257
258        self.fix_mkrescue(mkrescue_script)
259
260        self.logger.info('using grub-mkrescue for generating biosbits iso ...')
261
262        try:
263            if os.getenv('V'):
264                subprocess.check_call([mkrescue_script, '-o', iso_file,
265                                       bits_dir], stderr=subprocess.STDOUT)
266            else:
267                subprocess.check_call([mkrescue_script, '-o',
268                                      iso_file, bits_dir],
269                                      stderr=subprocess.DEVNULL,
270                                      stdout=subprocess.DEVNULL)
271        except Exception as e: # pylint: disable=broad-except
272            self.skipTest("Error while generating the bits iso. "
273                          "Pass V=1 in the environment to get more details. "
274                          + str(e))
275
276        self.assertTrue(os.access(iso_file, os.R_OK))
277
278        self.logger.info('iso file %s successfully generated.', iso_file)
279
280    def setUp(self): # pylint: disable=arguments-differ
281        super().setUp('qemu-system-')
282
283        self._baseDir = os.getenv('AVOCADO_TEST_BASEDIR')
284
285        # workdir could also be avocado's own workdir in self.workdir.
286        # At present, I prefer to maintain my own temporary working
287        # directory. It gives us more control over the generated bits
288        # log files and also for debugging, we may chose not to remove
289        # this working directory so that the logs and iso can be
290        # inspected manually and archived if needed.
291        self._workDir = tempfile.mkdtemp(prefix='acpi-bits-',
292                                         suffix='.tmp')
293        self.logger.info('working dir: %s', self._workDir)
294
295        prebuiltDir = os.path.join(self._workDir, 'prebuilt')
296        if not os.path.isdir(prebuiltDir):
297            os.mkdir(prebuiltDir, mode=0o775)
298
299        bits_zip_file = os.path.join(prebuiltDir, 'bits-%d-%s.zip'
300                                     %(self._bitsInternalVer,
301                                       self._bitsCommitHash))
302        grub_tar_file = os.path.join(prebuiltDir,
303                                     'bits-%d-%s-grub.tar.gz'
304                                     %(self._bitsInternalVer,
305                                       self._bitsCommitHash))
306
307        bitsLocalArtLoc = self.fetch_asset(self._bitsArtURL,
308                                           asset_hash=self._bitsArtSHA1Hash)
309        self.logger.info("downloaded bits artifacts to %s", bitsLocalArtLoc)
310
311        # extract the bits artifact in the temp working directory
312        with zipfile.ZipFile(bitsLocalArtLoc, 'r') as zref:
313            zref.extractall(prebuiltDir)
314
315        # extract the bits software in the temp working directory
316        with zipfile.ZipFile(bits_zip_file, 'r') as zref:
317            zref.extractall(self._workDir)
318
319        with tarfile.open(grub_tar_file, 'r', encoding='utf-8') as tarball:
320            tarball.extractall(self._workDir)
321
322        self.copy_test_scripts()
323        self.copy_bits_config()
324        self.generate_bits_iso()
325
326    def parse_log(self):
327        """parse the log generated by running bits tests and
328           check for failures.
329        """
330        debugconf = os.path.join(self._workDir, self._debugcon_log)
331        log = ""
332        with open(debugconf, 'r', encoding='utf-8') as filehandle:
333            log = filehandle.read()
334
335        matchiter = re.finditer(r'(.*Summary: )(\d+ passed), (\d+ failed).*',
336                                log)
337        for match in matchiter:
338            # verify that no test cases failed.
339            try:
340                self.assertEqual(match.group(3).split()[0], '0',
341                                 'Some bits tests seems to have failed. ' \
342                                 'Please check the test logs for more info.')
343            except AssertionError as e:
344                self._print_log(log)
345                raise e
346            else:
347                if os.getenv('V'):
348                    self._print_log(log)
349
350    def tearDown(self):
351        """
352           Lets do some cleanups.
353        """
354        if self._vm:
355            self.assertFalse(not self._vm.is_running)
356        self.logger.info('removing the work directory %s', self._workDir)
357        shutil.rmtree(self._workDir)
358        super().tearDown()
359
360    def test_acpi_smbios_bits(self):
361        """The main test case implementaion."""
362
363        iso_file = os.path.join(self._workDir,
364                                'bits-%d.iso' %self._bitsInternalVer)
365
366        self.assertTrue(os.access(iso_file, os.R_OK))
367
368        self._vm = QEMUBitsMachine(binary=self.qemu_bin,
369                                   base_temp_dir=self._workDir,
370                                   debugcon_log=self._debugcon_log,
371                                   debugcon_addr=self._debugcon_addr)
372
373        self._vm.add_args('-cdrom', '%s' %iso_file)
374        # the vm needs to be run under icount so that TCG emulation is
375        # consistent in terms of timing. smilatency tests have consistent
376        # timing requirements.
377        self._vm.add_args('-icount', 'auto')
378
379        args = " ".join(str(arg) for arg in self._vm.base_args()) + \
380            " " + " ".join(str(arg) for arg in self._vm.args)
381
382        self.logger.info("launching QEMU vm with the following arguments: %s",
383                         args)
384
385        self._vm.launch()
386        # biosbits has been configured to run all the specified test suites
387        # in batch mode and then automatically initiate a vm shutdown.
388        # sleep for maximum of one minute
389        max_sleep_time = time.monotonic() + 60
390        while self._vm.is_running() and time.monotonic() < max_sleep_time:
391            time.sleep(1)
392
393        self.assertFalse(time.monotonic() > max_sleep_time,
394                         'The VM seems to have failed to shutdown in time')
395
396        self.parse_log()
397