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._workDir = None
154        self._baseDir = None
155
156        self._debugcon_addr = '0x403'
157        self._debugcon_log = 'debugcon-log.txt'
158        self.logger = self.log
159
160    def _print_log(self, log):
161        self.logger.info('\nlogs from biosbits follows:')
162        self.logger.info('==========================================\n')
163        self.logger.info(log)
164        self.logger.info('==========================================\n')
165
166    def copy_bits_config(self):
167        """ copies the bios bits config file into bits.
168        """
169        config_file = 'bits-cfg.txt'
170        bits_config_dir = os.path.join(self._baseDir, 'acpi-bits',
171                                       'bits-config')
172        target_config_dir = os.path.join(self._workDir,
173                                         'bits-%d' %self.BITS_INTERNAL_VER,
174                                         'boot')
175        self.assertTrue(os.path.exists(bits_config_dir))
176        self.assertTrue(os.path.exists(target_config_dir))
177        self.assertTrue(os.access(os.path.join(bits_config_dir,
178                                               config_file), os.R_OK))
179        shutil.copy2(os.path.join(bits_config_dir, config_file),
180                     target_config_dir)
181        self.logger.info('copied config file %s to %s',
182                         config_file, target_config_dir)
183
184    def copy_test_scripts(self):
185        """copies the python test scripts into bits. """
186
187        bits_test_dir = os.path.join(self._baseDir, 'acpi-bits',
188                                     'bits-tests')
189        target_test_dir = os.path.join(self._workDir,
190                                       'bits-%d' %self.BITS_INTERNAL_VER,
191                                       'boot', 'python')
192
193        self.assertTrue(os.path.exists(bits_test_dir))
194        self.assertTrue(os.path.exists(target_test_dir))
195
196        for filename in os.listdir(bits_test_dir):
197            if os.path.isfile(os.path.join(bits_test_dir, filename)) and \
198               filename.endswith('.py2'):
199                # all test scripts are named with extension .py2 so that
200                # avocado does not try to load them. These scripts are
201                # written for python 2.7 not python 3 and hence if avocado
202                # loaded them, it would complain about python 3 specific
203                # syntaxes.
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        # workdir could also be avocado's own workdir in self.workdir.
293        # At present, I prefer to maintain my own temporary working
294        # directory. It gives us more control over the generated bits
295        # log files and also for debugging, we may chose not to remove
296        # this working directory so that the logs and iso can be
297        # inspected manually and archived if needed.
298        self._workDir = tempfile.mkdtemp(prefix='acpi-bits-',
299                                         suffix='.tmp')
300        self.logger.info('working dir: %s', self._workDir)
301
302        prebuiltDir = os.path.join(self._workDir, 'prebuilt')
303        if not os.path.isdir(prebuiltDir):
304            os.mkdir(prebuiltDir, mode=0o775)
305
306        bits_zip_file = os.path.join(prebuiltDir, 'bits-%d-%s.zip'
307                                     %(self.BITS_INTERNAL_VER,
308                                       self.BITS_COMMIT_HASH))
309        grub_tar_file = os.path.join(prebuiltDir,
310                                     'bits-%d-%s-grub.tar.gz'
311                                     %(self.BITS_INTERNAL_VER,
312                                       self.BITS_COMMIT_HASH))
313
314        bitsLocalArtLoc = self.ASSET_BITS.fetch()
315        self.logger.info("downloaded bits artifacts to %s", bitsLocalArtLoc)
316
317        # extract the bits artifact in the temp working directory
318        with zipfile.ZipFile(bitsLocalArtLoc, 'r') as zref:
319            zref.extractall(prebuiltDir)
320
321        # extract the bits software in the temp working directory
322        with zipfile.ZipFile(bits_zip_file, 'r') as zref:
323            zref.extractall(self._workDir)
324
325        with tarfile.open(grub_tar_file, 'r', encoding='utf-8') as tarball:
326            tarball.extractall(self._workDir)
327
328        self.copy_test_scripts()
329        self.copy_bits_config()
330        self.generate_bits_iso()
331
332    def parse_log(self):
333        """parse the log generated by running bits tests and
334           check for failures.
335        """
336        debugconf = os.path.join(self._workDir, self._debugcon_log)
337        log = ""
338        with open(debugconf, 'r', encoding='utf-8') as filehandle:
339            log = filehandle.read()
340
341        matchiter = re.finditer(r'(.*Summary: )(\d+ passed), (\d+ failed).*',
342                                log)
343        for match in matchiter:
344            # verify that no test cases failed.
345            try:
346                self.assertEqual(match.group(3).split()[0], '0',
347                                 'Some bits tests seems to have failed. ' \
348                                 'Please check the test logs for more info.')
349            except AssertionError as e:
350                self._print_log(log)
351                raise e
352            else:
353                if os.getenv('V') or os.getenv('BITS_DEBUG'):
354                    self._print_log(log)
355
356    def tearDown(self):
357        """
358           Lets do some cleanups.
359        """
360        if self._vm:
361            self.assertFalse(not self._vm.is_running)
362        if not os.getenv('BITS_DEBUG') and self._workDir:
363            self.logger.info('removing the work directory %s', self._workDir)
364            shutil.rmtree(self._workDir)
365        else:
366            self.logger.info('not removing the work directory %s ' \
367                             'as BITS_DEBUG is ' \
368                             'passed in the environment', self._workDir)
369        super().tearDown()
370
371    def test_acpi_smbios_bits(self):
372        """The main test case implementation."""
373
374        iso_file = os.path.join(self._workDir,
375                                'bits-%d.iso' %self.BITS_INTERNAL_VER)
376
377        self.assertTrue(os.access(iso_file, os.R_OK))
378
379        self._vm = QEMUBitsMachine(binary=self.qemu_bin,
380                                   base_temp_dir=self._workDir,
381                                   debugcon_log=self._debugcon_log,
382                                   debugcon_addr=self._debugcon_addr)
383
384        self._vm.add_args('-cdrom', '%s' %iso_file)
385        # the vm needs to be run under icount so that TCG emulation is
386        # consistent in terms of timing. smilatency tests have consistent
387        # timing requirements.
388        self._vm.add_args('-icount', 'auto')
389        # currently there is no support in bits for recognizing 64-bit SMBIOS
390        # entry points. QEMU defaults to 64-bit entry points since the
391        # upstream commit bf376f3020 ("hw/i386/pc: Default to use SMBIOS 3.0
392        # for newer machine models"). Therefore, enforce 32-bit entry point.
393        self._vm.add_args('-machine', 'smbios-entry-point-type=32')
394
395        # enable console logging
396        self._vm.set_console()
397        self._vm.launch()
398
399
400        # biosbits has been configured to run all the specified test suites
401        # in batch mode and then automatically initiate a vm shutdown.
402        # Set timeout to BITS_TIMEOUT for SHUTDOWN event from bits VM at par
403        # with the avocado test timeout.
404        self._vm.event_wait('SHUTDOWN', timeout=BITS_TIMEOUT)
405        self._vm.wait(timeout=None)
406        self.logger.debug("Checking console output ...")
407        self.parse_log()
408
409if __name__ == '__main__':
410    QemuBaseTest.main()
411