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 time
43import zipfile
44
45from pathlib import Path
46from typing import (
47    List,
48    Optional,
49    Sequence,
50)
51from qemu.machine import QEMUMachine
52from unittest import skipIf
53from qemu_test import QemuBaseTest, Asset
54
55deps = ["xorriso", "mformat"] # dependent tools needed in the test setup/box.
56supported_platforms = ['x86_64'] # supported test platforms.
57
58# default timeout of 120 secs is sometimes not enough for bits test.
59BITS_TIMEOUT = 200
60
61def which(tool):
62    """ looks up the full path for @tool, returns None if not found
63        or if @tool does not have executable permissions.
64    """
65    paths=os.getenv('PATH')
66    for p in paths.split(os.path.pathsep):
67        p = os.path.join(p, tool)
68        if os.path.exists(p) and os.access(p, os.X_OK):
69            return p
70    return None
71
72def missing_deps():
73    """ returns True if any of the test dependent tools are absent.
74    """
75    for dep in deps:
76        if which(dep) is None:
77            return True
78    return False
79
80def supported_platform():
81    """ checks if the test is running on a supported platform.
82    """
83    return platform.machine() in supported_platforms
84
85class QEMUBitsMachine(QEMUMachine): # pylint: disable=too-few-public-methods
86    """
87    A QEMU VM, with isa-debugcon enabled and bits iso passed
88    using -cdrom to QEMU commandline.
89
90    """
91    def __init__(self,
92                 binary: str,
93                 args: Sequence[str] = (),
94                 wrapper: Sequence[str] = (),
95                 name: Optional[str] = None,
96                 base_temp_dir: str = "/var/tmp",
97                 debugcon_log: str = "debugcon-log.txt",
98                 debugcon_addr: str = "0x403",
99                 qmp_timer: Optional[float] = None):
100        # pylint: disable=too-many-arguments
101
102        if name is None:
103            name = "qemu-bits-%d" % os.getpid()
104        super().__init__(binary, args, wrapper=wrapper, name=name,
105                         base_temp_dir=base_temp_dir,
106                         qmp_timer=qmp_timer)
107        self.debugcon_log = debugcon_log
108        self.debugcon_addr = debugcon_addr
109        self.base_temp_dir = base_temp_dir
110
111    @property
112    def _base_args(self) -> List[str]:
113        args = super()._base_args
114        args.extend([
115            '-chardev',
116            'file,path=%s,id=debugcon' %os.path.join(self.base_temp_dir,
117                                                     self.debugcon_log),
118            '-device',
119            'isa-debugcon,iobase=%s,chardev=debugcon' %self.debugcon_addr,
120        ])
121        return args
122
123    def base_args(self):
124        """return the base argument to QEMU binary"""
125        return self._base_args
126
127@skipIf(not supported_platform() or missing_deps(),
128        'unsupported platform or dependencies (%s) not installed' \
129        % ','.join(deps))
130class AcpiBitsTest(QemuBaseTest): #pylint: disable=too-many-instance-attributes
131    """
132    ACPI and SMBIOS tests using biosbits.
133    """
134    # in slower systems the test can take as long as 3 minutes to complete.
135    timeout = BITS_TIMEOUT
136
137    # following are some standard configuration constants
138    # gitlab CI does shallow clones of depth 20
139    BITS_INTERNAL_VER = 2020
140    # commit hash must match the artifact tag below
141    BITS_COMMIT_HASH = 'c7920d2b'
142    # this is the latest bits release as of today.
143    BITS_TAG = "qemu-bits-10262023"
144
145    ASSET_BITS = Asset(("https://gitlab.com/qemu-project/"
146                        "biosbits-bits/-/jobs/artifacts/%s/"
147                        "download?job=qemu-bits-build" % BITS_TAG),
148                       '1b8dd612c6831a6b491716a77acc486666aaa867051cdc34f7ce169c2e25f487')
149
150    def __init__(self, *args, **kwargs):
151        super().__init__(*args, **kwargs)
152        self._vm = None
153        self._baseDir = None
154
155        self._debugcon_addr = '0x403'
156        self._debugcon_log = 'debugcon-log.txt'
157        self.logger = self.log
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.BITS_INTERNAL_VER,
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.BITS_INTERNAL_VER,
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                # they are not run by accident.
200                #
201                # These scripts are intended to run inside the test VM
202                # and are written for python 2.7 not python 3, hence
203                # would cause syntax errors if loaded ouside the VM.
204                newfilename = os.path.splitext(filename)[0] + '.py'
205                shutil.copy2(os.path.join(bits_test_dir, filename),
206                             os.path.join(target_test_dir, newfilename))
207                self.logger.info('copied test file %s to %s',
208                                 filename, target_test_dir)
209
210                # now remove the pyc test file if it exists, otherwise the
211                # changes in the python test script won't be executed.
212                testfile_pyc = os.path.splitext(filename)[0] + '.pyc'
213                if os.access(os.path.join(target_test_dir, testfile_pyc),
214                             os.F_OK):
215                    os.remove(os.path.join(target_test_dir, testfile_pyc))
216                    self.logger.info('removed compiled file %s',
217                                     os.path.join(target_test_dir,
218                                     testfile_pyc))
219
220    def fix_mkrescue(self, mkrescue):
221        """ grub-mkrescue is a bash script with two variables, 'prefix' and
222            'libdir'. They must be pointed to the right location so that the
223            iso can be generated appropriately. We point the two variables to
224            the directory where we have extracted our pre-built bits grub
225            tarball.
226        """
227        grub_x86_64_mods = os.path.join(self.workdir, 'grub-inst-x86_64-efi')
228        grub_i386_mods = os.path.join(self.workdir, 'grub-inst')
229
230        self.assertTrue(os.path.exists(grub_x86_64_mods))
231        self.assertTrue(os.path.exists(grub_i386_mods))
232
233        new_script = ""
234        with open(mkrescue, 'r', encoding='utf-8') as filehandle:
235            orig_script = filehandle.read()
236            new_script = re.sub('(^prefix=)(.*)',
237                                r'\1"%s"' %grub_x86_64_mods,
238                                orig_script, flags=re.M)
239            new_script = re.sub('(^libdir=)(.*)', r'\1"%s/lib"' %grub_i386_mods,
240                                new_script, flags=re.M)
241
242        with open(mkrescue, 'w', encoding='utf-8') as filehandle:
243            filehandle.write(new_script)
244
245    def generate_bits_iso(self):
246        """ Uses grub-mkrescue to generate a fresh bits iso with the python
247            test scripts
248        """
249        bits_dir = os.path.join(self.workdir,
250                                'bits-%d' %self.BITS_INTERNAL_VER)
251        iso_file = os.path.join(self.workdir,
252                                'bits-%d.iso' %self.BITS_INTERNAL_VER)
253        mkrescue_script = os.path.join(self.workdir,
254                                       'grub-inst-x86_64-efi', 'bin',
255                                       'grub-mkrescue')
256
257        self.assertTrue(os.access(mkrescue_script,
258                                  os.R_OK | os.W_OK | os.X_OK))
259
260        self.fix_mkrescue(mkrescue_script)
261
262        self.logger.info('using grub-mkrescue for generating biosbits iso ...')
263
264        try:
265            if os.getenv('V') or os.getenv('BITS_DEBUG'):
266                proc = subprocess.run([mkrescue_script, '-o', iso_file,
267                                       bits_dir],
268                                      stdout=subprocess.PIPE,
269                                      stderr=subprocess.STDOUT,
270                                      check=True)
271                self.logger.info("grub-mkrescue output %s" % proc.stdout)
272            else:
273                subprocess.check_call([mkrescue_script, '-o',
274                                      iso_file, bits_dir],
275                                      stderr=subprocess.DEVNULL,
276                                      stdout=subprocess.DEVNULL)
277        except Exception as e: # pylint: disable=broad-except
278            self.skipTest("Error while generating the bits iso. "
279                          "Pass V=1 in the environment to get more details. "
280                          + str(e))
281
282        self.assertTrue(os.access(iso_file, os.R_OK))
283
284        self.logger.info('iso file %s successfully generated.', iso_file)
285
286    def setUp(self): # pylint: disable=arguments-differ
287        super().setUp('qemu-system-')
288        self.logger = self.log
289
290        self._baseDir = Path(__file__).parent
291
292        prebuiltDir = os.path.join(self.workdir, 'prebuilt')
293        if not os.path.isdir(prebuiltDir):
294            os.mkdir(prebuiltDir, mode=0o775)
295
296        bits_zip_file = os.path.join(prebuiltDir, 'bits-%d-%s.zip'
297                                     %(self.BITS_INTERNAL_VER,
298                                       self.BITS_COMMIT_HASH))
299        grub_tar_file = os.path.join(prebuiltDir,
300                                     'bits-%d-%s-grub.tar.gz'
301                                     %(self.BITS_INTERNAL_VER,
302                                       self.BITS_COMMIT_HASH))
303
304        bitsLocalArtLoc = self.ASSET_BITS.fetch()
305        self.logger.info("downloaded bits artifacts to %s", bitsLocalArtLoc)
306
307        # extract the bits artifact in the temp working directory
308        with zipfile.ZipFile(bitsLocalArtLoc, 'r') as zref:
309            zref.extractall(prebuiltDir)
310
311        # extract the bits software in the temp working directory
312        with zipfile.ZipFile(bits_zip_file, 'r') as zref:
313            zref.extractall(self.workdir)
314
315        with tarfile.open(grub_tar_file, 'r', encoding='utf-8') as tarball:
316            tarball.extractall(self.workdir)
317
318        self.copy_test_scripts()
319        self.copy_bits_config()
320        self.generate_bits_iso()
321
322    def parse_log(self):
323        """parse the log generated by running bits tests and
324           check for failures.
325        """
326        debugconf = os.path.join(self.workdir, self._debugcon_log)
327        log = ""
328        with open(debugconf, 'r', encoding='utf-8') as filehandle:
329            log = filehandle.read()
330
331        matchiter = re.finditer(r'(.*Summary: )(\d+ passed), (\d+ failed).*',
332                                log)
333        for match in matchiter:
334            # verify that no test cases failed.
335            try:
336                self.assertEqual(match.group(3).split()[0], '0',
337                                 'Some bits tests seems to have failed. ' \
338                                 'Please check the test logs for more info.')
339            except AssertionError as e:
340                self._print_log(log)
341                raise e
342            else:
343                if os.getenv('V') or os.getenv('BITS_DEBUG'):
344                    self._print_log(log)
345
346    def tearDown(self):
347        """
348           Lets do some cleanups.
349        """
350        if self._vm:
351            self.assertFalse(not self._vm.is_running)
352        super().tearDown()
353
354    def test_acpi_smbios_bits(self):
355        """The main test case implementation."""
356
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    QemuBaseTest.main()
392