Skip to content

Commit 151626c

Browse files
committed
[qa] Add a mechanism for running automatic wimboot tests
Create a test framework that will spin up a temporary virtual machine configured to boot via iPXE and wimboot into a Windows PE environment, and verify that the virtual machine correctly reaches the point of running Windows applications. Virtual machines are created via libvirt using QEMU's user-mode networking, and can be run as an unprivileged user. Boot files are served from a temporary local HTTP server running on an unprivileged port and accessible only to localhost. A QR code containing a random UUID is generated by the test framework and added to wimboot's virtual filesystem along with a winpeshl.ini that causes the QR code to be displayed once Windows has started up. The test framework takes screenshots of the virtual machine once per second and looks for the QR code to determine a successful test result. An interactive viewer is available when running locally. The captured wimboot output along with the final screenshot from each test is saved to allow for debugging of test runs performed in a headless system (e.g. as part of a CI workflow). Signed-off-by: Michael Brown <mbrown@fensystems.co.uk>
1 parent 9d2847c commit 151626c

File tree

16 files changed

+333
-0
lines changed

16 files changed

+333
-0
lines changed

test/bootmgr.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
name: Test bootmgr.exe extraction
2+
version: win7
3+
arch: x64
4+
bootmgr: true
5+
logcheck:
6+
- "found bootmgr"
7+
- "extracting embedded bootmgr.exe"
8+
- "Using bootmgr.exe"

test/images/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*

test/in/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.ipxe
2+
*.txt

test/out/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.log
2+
*.png

test/testwimboot

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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))

test/wimboot

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../wimboot

test/win10.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: Windows 10
2+
version: win10
3+
arch: x64

test/win10_uefi.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
name: Windows 10 (UEFI)
2+
version: win10
3+
arch: x64
4+
uefi: true

test/win10_x86.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: Windows 10 (32-bit)
2+
version: win10
3+
arch: x86

test/win7.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: Windows 7
2+
version: win7
3+
arch: x64

0 commit comments

Comments
 (0)