#!/usr/bin/python # # Copyright (C) 2014 Red Hat, Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published # by the Free Software Foundation; either version 2.1 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . # # Author: Chris Lumens __all__ = ["Creator", "OutsideMixin"] import blivet from contextlib import contextmanager import os import shutil import subprocess import tempfile import unittest import errno # Copied from python's subprocess.py def eintr_retry_call(func, *args): """Retry an interruptible system call if interrupted.""" while True: try: return func(*args) except (OSError, IOError) as e: if e.errno == errno.EINTR: continue raise class Creator(object): """A Creator subclass defines all the parameters for making a VM to run a test against as well as handles creating and running that VM, inspecting results, and managing temporary data. Most Creator subclasses will only need to define the following four attributes: drives -- A list of tuples describing disk images to create. Each tuple is the name and size, in GB. environ -- A dictionary of environment variables that should be added to the environment the test suite will run under. name -- A unique string that names a Creator. This name will be used in creating the results directory (and perhaps other places in the future) so make sure it doesn't conflict with another object. reqMemory -- The amount of memory this VM needs, in MB. The default is 2048, which seems to be the minimum required to make things run quickly. Redefine if you need more. tests -- A list of tuples describing which test cases make up this test. Each tuple is the name of the module containing the test case (minus the leading "inside." and the name of the test case class. Tests will be run in the order provided. """ drives = [] environ = {} name = "Creator" reqMemory = 2048 tests = [] def __init__(self): self._drivePaths = {} self._mountpoint = None self._proc = None self._tempdir = None def archive(self): """Copy all log files and other test results to a subdirectory of the given resultsdir. If logs are no longer available, this method does nothing. It is up to the caller to make sure logs are available beforehand and clean up afterwards. """ from testconfig import config if not os.path.ismount(self.mountpoint): return shutil.copytree(self.mountpoint + "/result", config["resultsdir"] + "/" + self.name) def cleanup(self): """Remove all disk images used during this test case and the temporary directory they were stored in. """ shutil.rmtree(self.tempdir, ignore_errors=True) os.unlink(self._drivePaths["suite"]) def die(self): """Kill any running qemu process previously started by this test.""" if self._proc: self._proc.kill() self._proc = None def makeDrives(self): """Create all hard drive images associated with this test. Images must be listed in Creator.drives and will be stored in a temporary directory this method creates. It is up to the caller to remove everything later by calling Creator.cleanup. """ for (drive, size) in self.drives: (fd, diskimage) = tempfile.mkstemp(dir=self.tempdir) eintr_retry_call(os.close, fd) subprocess.call(["/usr/bin/qemu-img", "create", "-f", "qcow2", diskimage, "%sG" % size], stdout=open("/dev/null", "w")) self._drivePaths[drive] = diskimage @property def template(self): with open("outside/template.py", "r") as f: return f.read() def makeSuite(self): """The suite is a small disk image attached to every test VM automatically by the test framework. It includes all the inside/ stuff, a special suite.py file that will be automatically run by the live CD (and is what actually runs the test), and a directory structure for reporting results. It is mounted under Creator.mountpoint as needed. This method creates the suite image and adds it to the internal list of images associated with this test. Note that because this image is attached to the VM, anaconda will always see two hard drives and thus will never automatically select disks. Note also that this means tests must be careful to not select this disk. """ from testconfig import config # First, create a disk image and put a filesystem on it. b = blivet.Blivet() # pylint: disable=undefined-variable disk1_path = blivet.util.create_sparse_tempfile("suite", blivet.size.Size("11 MB")) b.config.diskImages["suite"] = disk1_path b.reset() try: disk1 = b.devicetree.getDeviceByName("suite") b.initializeDisk(disk1) part = b.newPartition(size=blivet.size.Size("10 MB"), parents=[disk1]) b.createDevice(part) fmt = blivet.formats.getFormat("ext4", label="ANACTEST", mountpoint=self.mountpoint) b.formatDevice(part, fmt) blivet.partitioning.doPartitioning(b) b.doIt() fmt.mount() # Create the directory structure needed for storing results. os.makedirs(self.mountpoint + "/result/anaconda") # Copy all the inside stuff into the mountpoint. shutil.copytree("inside", self.mountpoint + "/inside") # Create the suite file, which contains all the test cases to run and is how # the VM will figure out what to run. with open(self.mountpoint + "/suite.py", "w") as f: imports = map(lambda (path, cls): " from inside.%s import %s" % (path, cls), self.tests) addtests = map(lambda (path, cls): " s.addTest(%s())" % cls, self.tests) f.write(self.template % {"environ": " os.environ.update(%s)" % self.environ, "imports": "\n".join(imports), "addtests": "\n".join(addtests), "anacondaArgs": config.get("anacondaArgs", "").strip('"')}) finally: # pylint: disable=undefined-variable b.devicetree.teardownDiskImages() shutil.rmtree(self.mountpoint) # This ensures it gets passed to qemu-kvm as a disk arg. self._drivePaths["suite"] = disk1_path @contextmanager def suiteMounted(self): """This context manager allows for wrapping code that needs to access the suite. It mounts the disk image beforehand and unmounts it afterwards. """ if self._drivePaths.get("suite", "") == "": return b = blivet.Blivet() b.config.diskImages["suite"] = self._drivePaths["suite"] b.reset() disk = b.devicetree.getDeviceByName("suite") part = b.devicetree.getChildren(disk)[0] part.format.mountpoint = self.mountpoint part.format.mount() try: yield except: raise finally: part.format.unmount() # pylint: disable=undefined-variable b.devicetree.teardownDiskImages() def run(self): """Given disk images previously created by Creator.makeDrives and Creator.makeSuite, start qemu and wait for it to terminate. """ from testconfig import config args = ["/usr/bin/qemu-kvm", "-vnc", "localhost:2", "-m", str(self.reqMemory), "-boot", "d", "-drive", "file=%s,media=cdrom" % config["liveImage"]] for drive in self._drivePaths.values(): args += ["-drive", "file=%s,media=disk" % drive] # Save a reference to the running qemu process so we can later kill # it if necessary. For now, the only reason we'd want to kill it is # an expired timer. self._proc = subprocess.Popen(args) self._proc.wait() self._proc = None @property def mountpoint(self): """The directory where the suite is mounted. This is a subdirectory of Creator.tempdir, and it is assumed the mountpoint directory (though not the mount itself) exists throughout this test. """ if not self._mountpoint: self._mountpoint = tempfile.mkdtemp(dir=self.tempdir) return self._mountpoint @property def tempdir(self): """The temporary directory used to store disk images and other data this test requires. This directory will be removed by Creator.cleanup. It is up to the caller to call that method, though. """ if not self._tempdir: self._tempdir = tempfile.mkdtemp(prefix="%s-" % self.name, dir="/var/tmp") return self._tempdir class OutsideMixin(object): """A BaseOutsideTestCase subclass is the interface between the unittest framework and a running VM. It interfaces with an associated Creator object to create devices and fire up a VM, and also handles actually reporting a result that unittest knows how to process. Each subclass will likely only want to define a single attribute: creatorClass -- A Creator subclass that goes with this test. """ creatorClass = None def archive(self): self.creator.archive() def runTest(self): self.creator.run() with self.creator.suiteMounted(): self.assertTrue(os.path.exists(self.creator.mountpoint + "/result"), msg="results directory does not exist") self.archive() self.assertFalse(os.path.exists(self.creator.mountpoint + "/result/unittest-failures"), msg="automated UI test %s failed" % self.creator.name) def setUp(self): # pylint: disable=not-callable self.creator = self.creatorClass() self.creator.makeDrives() self.creator.makeSuite() def tearDown(self): self.creator.cleanup()