|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +import argparse |
| 4 | +from enum import IntEnum |
| 5 | +from http import HTTPStatus |
| 6 | +import http.server |
| 7 | +import io |
| 8 | +import os |
| 9 | +import re |
| 10 | +import subprocess |
| 11 | +import sys |
| 12 | +import textwrap |
| 13 | +import time |
| 14 | +import threading |
| 15 | +from uuid import uuid4 |
| 16 | + |
| 17 | +import libvirt |
| 18 | +from lxml import etree |
| 19 | +from PIL import Image |
| 20 | +import qrcode |
| 21 | +import yaml |
| 22 | +import zbar |
| 23 | + |
| 24 | +QEMU_NS = 'http://libvirt.org/schemas/domain/qemu/1.0' |
| 25 | +etree.register_namespace('qemu', QEMU_NS) |
| 26 | + |
| 27 | +OVMF_PATH = '/usr/share/OVMF/OVMF_CODE.fd' |
| 28 | + |
| 29 | + |
| 30 | +class Verbosity(IntEnum): |
| 31 | + """Verbosity level""" |
| 32 | + |
| 33 | + PROGRESS = 1 |
| 34 | + DEBUG = 2 |
| 35 | + |
| 36 | + |
| 37 | +class HTTPRequestHandler(http.server.SimpleHTTPRequestHandler): |
| 38 | + """HTTP request handler""" |
| 39 | + |
| 40 | + def log_request(self, code='-', **kwargs): |
| 41 | + """Inhibit successful request logging""" |
| 42 | + if isinstance(code, HTTPStatus): |
| 43 | + code = code.value |
| 44 | + if code == HTTPStatus.OK: |
| 45 | + return |
| 46 | + super().log_request(code=code, **kwargs) |
| 47 | + |
| 48 | + |
| 49 | +def start_httpd(): |
| 50 | + """Start HTTP daemon thread serving static files""" |
| 51 | + httpd = http.server.HTTPServer(('localhost', 0), HTTPRequestHandler) |
| 52 | + threading.Thread(target=httpd.serve_forever, daemon=True).start() |
| 53 | + return httpd.server_address[1] |
| 54 | + |
| 55 | + |
| 56 | +def ipxe_script(uuid, version, arch, bootmgr, bootargs): |
| 57 | + """Construct iPXE boot script""" |
| 58 | + script = textwrap.dedent(f""" |
| 59 | + #!ipxe |
| 60 | + kernel ../wimboot {bootargs} |
| 61 | + initrd -n qr.txt qr-{uuid}.txt qr.txt |
| 62 | + initrd ../winpeshl.ini winpeshl.ini |
| 63 | + """).lstrip() |
| 64 | + if bootmgr: |
| 65 | + script += textwrap.dedent(f""" |
| 66 | + initrd ../images/{version}/{arch}/bootmgr bootmgr |
| 67 | + """).lstrip() |
| 68 | + script += textwrap.dedent(f""" |
| 69 | + initrd ../images/{version}/{arch}/boot/bcd BCD |
| 70 | + initrd ../images/{version}/{arch}/boot/boot.sdi boot.sdi |
| 71 | + initrd ../images/{version}/{arch}/sources/boot.wim boot.wim |
| 72 | + boot |
| 73 | + """).lstrip() |
| 74 | + return script |
| 75 | + |
| 76 | + |
| 77 | +def vm_xml(virttype, name, uuid, memory, uefi, logfile, romfile, booturl): |
| 78 | + """Construct XML description of VM""" |
| 79 | + x_domain = etree.Element('domain') |
| 80 | + x_domain.attrib['type'] = virttype |
| 81 | + x_name = etree.SubElement(x_domain, 'name') |
| 82 | + x_name.text = name |
| 83 | + x_uuid = etree.SubElement(x_domain, 'uuid') |
| 84 | + x_uuid.text = uuid |
| 85 | + x_os = etree.SubElement(x_domain, 'os') |
| 86 | + x_ostype = etree.SubElement(x_os, 'type') |
| 87 | + x_ostype.text = 'hvm' |
| 88 | + x_ostype.attrib['arch'] = 'x86_64' |
| 89 | + if uefi: |
| 90 | + x_loader = etree.SubElement(x_os, 'loader') |
| 91 | + x_loader.text = OVMF_PATH |
| 92 | + x_features = etree.SubElement(x_domain, 'features') |
| 93 | + x_acpi = etree.SubElement(x_features, 'acpi') |
| 94 | + x_boot = etree.SubElement(x_os, 'boot') |
| 95 | + x_boot.attrib['dev'] = 'network' |
| 96 | + x_memory = etree.SubElement(x_domain, 'memory') |
| 97 | + x_memory.text = str(memory) |
| 98 | + x_memory.attrib['unit'] = 'MiB' |
| 99 | + x_devices = etree.SubElement(x_domain, 'devices') |
| 100 | + x_graphics = etree.SubElement(x_devices, 'graphics') |
| 101 | + x_graphics.attrib['type'] = 'spice' |
| 102 | + x_video = etree.SubElement(x_devices, 'video') |
| 103 | + x_video_model = etree.SubElement(x_video, 'model') |
| 104 | + x_video_model.attrib['type'] = 'vga' |
| 105 | + x_interface = etree.SubElement(x_devices, 'interface') |
| 106 | + x_interface.attrib['type'] = 'user' |
| 107 | + x_interface_model = etree.SubElement(x_interface, 'model') |
| 108 | + x_interface_model.attrib['type'] = 'e1000' |
| 109 | + if romfile: |
| 110 | + x_rom = etree.SubElement(x_interface, 'rom') |
| 111 | + x_rom.attrib['file'] = romfile |
| 112 | + x_qemu = etree.SubElement(x_domain, '{%s}commandline' % QEMU_NS) |
| 113 | + for arg in ('-set', 'netdev.%s.bootfile=%s' % ('hostnet0', booturl), |
| 114 | + '-debugcon', 'file:%s' % logfile): |
| 115 | + x_qemu_arg = etree.SubElement(x_qemu, '{%s}arg' % QEMU_NS) |
| 116 | + x_qemu_arg.attrib['value'] = arg |
| 117 | + return etree.tostring(x_domain, pretty_print=True).decode().strip() |
| 118 | + |
| 119 | + |
| 120 | +def screenshot(vm, screen=0): |
| 121 | + """Take screenshot of VM""" |
| 122 | + stream = vm.connect().newStream() |
| 123 | + vm.screenshot(stream, screen) |
| 124 | + with io.BytesIO() as fh: |
| 125 | + stream.recvAll(lambda s, d, f: f.write(d), fh) |
| 126 | + image = Image.open(fh) |
| 127 | + image.load() |
| 128 | + return image |
| 129 | + |
| 130 | + |
| 131 | +def qrcodes(image): |
| 132 | + """Get QR codes within an image""" |
| 133 | + zimage = zbar.Image(width=image.width, height=image.height, format='Y800', |
| 134 | + data=image.convert('RGBA').convert('L').tobytes()) |
| 135 | + zbar.ImageScanner().scan(zimage) |
| 136 | + return [x.data for x in zimage] |
| 137 | + |
| 138 | + |
| 139 | +# Parse command-line arguments |
| 140 | +parser = argparse.ArgumentParser(description="Run wimboot test case") |
| 141 | +parser.add_argument('--connection', '-c', default='qemu:///session', |
| 142 | + help="Libvirt connection URI", metavar='URI') |
| 143 | +parser.add_argument('--interactive', '-i', action='store_true', |
| 144 | + help="Launch interactive viewer") |
| 145 | +parser.add_argument('--romfile', '-r', metavar='FILE', |
| 146 | + help="iPXE boot ROM") |
| 147 | +parser.add_argument('--timeout', '-t', type=int, default=60, metavar='T', |
| 148 | + help="Timeout (in seconds)") |
| 149 | +parser.add_argument('--verbose', '-v', action='count', default=0, |
| 150 | + help="Increase verbosity") |
| 151 | +parser.add_argument('test', nargs='+', help="YAML test case(s)") |
| 152 | +args = parser.parse_args() |
| 153 | + |
| 154 | +# Start HTTP daemon |
| 155 | +http_port = start_httpd() |
| 156 | + |
| 157 | +# Open libvirt connection |
| 158 | +virt = libvirt.open(args.connection) |
| 159 | + |
| 160 | +# Select a supported virtualisation type |
| 161 | +try: |
| 162 | + virt.getDomainCapabilities(virttype='kvm') |
| 163 | + virttype = 'kvm' |
| 164 | +except libvirt.libvirtError: |
| 165 | + virttype = 'qemu' |
| 166 | + |
| 167 | +# Run test cases |
| 168 | +failures = [] |
| 169 | +for test_file in args.test: |
| 170 | + |
| 171 | + # Load test case |
| 172 | + with open(test_file, 'rt') as fh: |
| 173 | + test = yaml.safe_load(fh) |
| 174 | + key = os.path.splitext(test_file)[0] |
| 175 | + name = test.get('name', key) |
| 176 | + version = test['version'] |
| 177 | + arch = test['arch'] |
| 178 | + uefi = test.get('uefi', False) |
| 179 | + memory = test.get('memory', 2048) |
| 180 | + bootmgr = test.get('bootmgr', False) |
| 181 | + bootargs = test.get('bootargs', '') |
| 182 | + logcheck = test.get('logcheck', []) |
| 183 | + |
| 184 | + # Generate test UUID |
| 185 | + uuid = uuid4().hex |
| 186 | + |
| 187 | + # Construct boot script |
| 188 | + script = ipxe_script(uuid, version, arch, bootmgr, bootargs) |
| 189 | + if args.verbose >= Verbosity.DEBUG: |
| 190 | + print("%s boot script:\n%s\n" % (name, script.strip())) |
| 191 | + bootfile = 'in/boot-%s.ipxe' % uuid |
| 192 | + with open(bootfile, 'wt') as fh: |
| 193 | + fh.write(script) |
| 194 | + |
| 195 | + # Generate test QR code |
| 196 | + qr = qrcode.QRCode() |
| 197 | + qr.add_data(uuid) |
| 198 | + qrfile = 'in/qr-%s.txt' % uuid |
| 199 | + with open(qrfile, 'wt', newline='\r\n') as fh: |
| 200 | + qr.print_ascii(out=fh) |
| 201 | + |
| 202 | + # Construct debug log filename |
| 203 | + logfile = os.path.abspath('out/%s.log' % key) |
| 204 | + |
| 205 | + # Construct boot ROM filename |
| 206 | + romfile = os.path.abspath(args.romfile) if args.romfile else None |
| 207 | + |
| 208 | + # Construct boot URL |
| 209 | + booturl = 'http://${next-server}:%s/in/boot-%s.ipxe' % (http_port, uuid) |
| 210 | + |
| 211 | + # Launch VM |
| 212 | + xml = vm_xml(virttype, name, uuid, memory, uefi, logfile, romfile, booturl) |
| 213 | + if args.verbose >= Verbosity.DEBUG: |
| 214 | + print("%s definition:\n%s\n" % (name, xml)) |
| 215 | + vm = virt.createXML(xml, flags=libvirt.VIR_DOMAIN_START_AUTODESTROY) |
| 216 | + if args.verbose >= Verbosity.PROGRESS: |
| 217 | + print("%s launched as ID %d" % (name, vm.ID())) |
| 218 | + if args.verbose >= Verbosity.DEBUG: |
| 219 | + print("%s description:\n%s\n" % (name, vm.XMLDesc().strip())) |
| 220 | + |
| 221 | + # Launch interactive viewer, if requested |
| 222 | + if args.interactive: |
| 223 | + viewer = subprocess.Popen(['virt-viewer', '--attach', |
| 224 | + '--id', str(vm.ID())]) |
| 225 | + else: |
| 226 | + viewer = None |
| 227 | + |
| 228 | + # Wait for test to complete |
| 229 | + timeout = time.clock_gettime(time.CLOCK_MONOTONIC) + args.timeout |
| 230 | + passed = False |
| 231 | + aborted = False |
| 232 | + while time.clock_gettime(time.CLOCK_MONOTONIC) < timeout: |
| 233 | + |
| 234 | + # Sleep for a second |
| 235 | + time.sleep(1) |
| 236 | + |
| 237 | + # Take screenshot |
| 238 | + image = screenshot(vm) |
| 239 | + image.save('out/%s.png' % key) |
| 240 | + |
| 241 | + # Abort if viewer has been closed |
| 242 | + if viewer and viewer.poll() is not None: |
| 243 | + print("%s aborted" % name) |
| 244 | + aborted = True |
| 245 | + break |
| 246 | + |
| 247 | + # Wait for QR code to appear |
| 248 | + if uuid not in qrcodes(image): |
| 249 | + continue |
| 250 | + |
| 251 | + # Check for required log messages |
| 252 | + with open(logfile, 'rt') as fh: |
| 253 | + log = fh.read() |
| 254 | + logfail = [x for x in logcheck if not re.search(x, log)] |
| 255 | + if logfail: |
| 256 | + print("%s failed log check: %s" % (name, ', '.join(logfail))) |
| 257 | + break |
| 258 | + |
| 259 | + # Pass test |
| 260 | + if args.verbose >= Verbosity.PROGRESS: |
| 261 | + print("%s passed" % name) |
| 262 | + passed = True |
| 263 | + break |
| 264 | + |
| 265 | + else: |
| 266 | + |
| 267 | + # Timeout |
| 268 | + print("%s timed out" % name) |
| 269 | + |
| 270 | + # Destroy VM |
| 271 | + vm.destroy() |
| 272 | + |
| 273 | + # Remove input files |
| 274 | + os.unlink(qrfile) |
| 275 | + os.unlink(bootfile) |
| 276 | + |
| 277 | + # Record failure, if applicable |
| 278 | + if not passed: |
| 279 | + failures.append(name) |
| 280 | + |
| 281 | + # Abort testing, if applicable |
| 282 | + if aborted: |
| 283 | + break |
| 284 | + |
| 285 | +# Report any failures |
| 286 | +if failures: |
| 287 | + sys.exit("Failures: %s" % ','.join(failures)) |
0 commit comments