···
1
+
#! /somewhere/python3
3
+
from contextlib import contextmanager
4
+
from xml.sax.saxutils import XMLGenerator
83
+
def eprint(*args, **kwargs):
84
+
print(*args, file=sys.stderr, **kwargs)
87
+
def create_vlan(vlan_nr):
89
+
log.log("starting VDE switch for network {}".format(vlan_nr))
90
+
vde_socket = os.path.abspath("./vde{}.ctl".format(vlan_nr))
91
+
pty_master, pty_slave = pty.openpty()
92
+
vde_process = subprocess.Popen(
93
+
["vde_switch", "-s", vde_socket, "--dirmode", "0777"],
96
+
stdout=subprocess.PIPE,
97
+
stderr=subprocess.PIPE,
100
+
fd = os.fdopen(pty_master, "w")
101
+
fd.write("version\n")
102
+
# TODO: perl version checks if this can be read from
103
+
# an if not, dies. we could hang here forever. Fix it.
104
+
vde_process.stdout.readline()
105
+
if not os.path.exists(os.path.join(vde_socket, "ctl")):
106
+
raise Exception("cannot start vde_switch")
108
+
return (vlan_nr, vde_socket, vde_process, fd)
112
+
"""Call the given function repeatedly, with 1 second intervals,
113
+
until it returns True or a timeout is reached.
116
+
for _ in range(900):
122
+
raise Exception("action timed out")
126
+
def __init__(self):
127
+
self.logfile = os.environ.get("LOGFILE", "/dev/null")
128
+
self.logfile_handle = open(self.logfile, "wb")
129
+
self.xml = XMLGenerator(self.logfile_handle, encoding="utf-8")
130
+
self.queue = queue.Queue(1000)
132
+
self.xml.startDocument()
133
+
self.xml.startElement("logfile", attrs={})
136
+
self.xml.endElement("logfile")
137
+
self.xml.endDocument()
138
+
self.logfile_handle.close()
140
+
def sanitise(self, message):
141
+
return "".join(ch for ch in message if unicodedata.category(ch)[0] != "C")
143
+
def maybe_prefix(self, message, attributes):
144
+
if "machine" in attributes:
145
+
return "{}: {}".format(attributes["machine"], message)
148
+
def log_line(self, message, attributes):
149
+
self.xml.startElement("line", attributes)
150
+
self.xml.characters(message)
151
+
self.xml.endElement("line")
153
+
def log(self, message, attributes={}):
154
+
eprint(self.maybe_prefix(message, attributes))
155
+
self.drain_log_queue()
156
+
self.log_line(message, attributes)
158
+
def enqueue(self, message):
159
+
self.queue.put(message)
161
+
def drain_log_queue(self):
164
+
item = self.queue.get_nowait()
165
+
attributes = {"machine": item["machine"], "type": "serial"}
166
+
self.log_line(self.sanitise(item["msg"]), attributes)
167
+
except queue.Empty:
171
+
def nested(self, message, attributes={}):
172
+
eprint(self.maybe_prefix(message, attributes))
174
+
self.xml.startElement("nest", attrs={})
175
+
self.xml.startElement("head", attributes)
176
+
self.xml.characters(message)
177
+
self.xml.endElement("head")
180
+
self.drain_log_queue()
182
+
self.drain_log_queue()
184
+
self.log("({:.2f} seconds)".format(toc - tic))
186
+
self.xml.endElement("nest")
190
+
def __init__(self, args):
192
+
self.name = args["name"]
194
+
self.name = "machine"
196
+
cmd = args["startCommand"]
197
+
self.name = re.search("run-(.+)-vm$", cmd).group(1)
200
+
except AttributeError:
203
+
self.script = args.get("startCommand", self.create_startcommand(args))
205
+
tmp_dir = os.environ.get("TMPDIR", tempfile.gettempdir())
207
+
def create_dir(name):
208
+
path = os.path.join(tmp_dir, name)
209
+
os.makedirs(path, mode=0o700, exist_ok=True)
212
+
self.state_dir = create_dir("vm-state-{}".format(self.name))
213
+
self.shared_dir = create_dir("xchg-shared")
215
+
self.booted = False
216
+
self.connected = False
219
+
self.monitor = None
220
+
self.logger = args["log"]
221
+
self.allow_reboot = args.get("allowReboot", False)
224
+
def create_startcommand(args):
225
+
net_backend = "-netdev user,id=net0"
226
+
net_frontend = "-device virtio-net-pci,netdev=net0"
228
+
if "netBackendArgs" in args:
229
+
net_backend += "," + args["netBackendArgs"]
231
+
if "netFrontendArgs" in args:
232
+
net_frontend += "," + args["netFrontendArgs"]
235
+
"qemu-kvm -m 384 " + net_backend + " " + net_frontend + " $QEMU_OPTS "
239
+
hda_path = os.path.abspath(args["hda"])
240
+
if args.get("hdaInterface", "") == "scsi":
242
+
"-drive id=hda,file="
244
+
+ ",werror=report,if=none "
245
+
+ "-device scsi-hd,drive=hda "
252
+
+ args["hdaInterface"]
253
+
+ ",werror=report "
256
+
if "cdrom" in args:
257
+
start_command += "-cdrom " + args["cdrom"] + " "
261
+
"-device piix3-usb-uhci -drive "
262
+
+ "id=usbdisk,file="
264
+
+ ",if=none,readonly "
265
+
+ "-device usb-storage,drive=usbdisk "
268
+
start_command += "-bios " + args["bios"] + " "
270
+
start_command += args.get("qemuFlags", "")
272
+
return start_command
275
+
return self.booted and self.connected
277
+
def log(self, msg):
278
+
self.logger.log(msg, {"machine": self.name})
280
+
def nested(self, msg, attrs={}):
281
+
my_attrs = {"machine": self.name}
282
+
my_attrs.update(attrs)
283
+
return self.logger.nested(msg, my_attrs)
285
+
def wait_for_monitor_prompt(self):
287
+
answer = self.monitor.recv(1024).decode()
288
+
if answer.endswith("(qemu) "):
291
+
def send_monitor_command(self, command):
292
+
message = ("{}\n".format(command)).encode()
293
+
self.log("sending monitor command: {}".format(command))
294
+
self.monitor.send(message)
295
+
return self.wait_for_monitor_prompt()
297
+
def wait_for_unit(self, unit, user=None):
299
+
info = self.get_unit_info(unit, user)
300
+
state = info["ActiveState"]
301
+
if state == "failed":
302
+
raise Exception('unit "{}" reached state "{}"'.format(unit, state))
304
+
if state == "inactive":
305
+
status, jobs = self.systemctl("list-jobs --full 2>&1", user)
306
+
if "No jobs" in jobs:
307
+
info = self.get_unit_info(unit)
308
+
if info["ActiveState"] == state:
311
+
'unit "{}" is inactive and there ' "are no pending jobs"
314
+
if state == "active":
317
+
def get_unit_info(self, unit, user=None):
318
+
status, lines = self.systemctl('--no-pager show "{}"'.format(unit), user)
322
+
line_pattern = re.compile(r"^([^=]+)=(.*)$")
324
+
def tuple_from_line(line):
325
+
match = line_pattern.match(line)
326
+
return match[1], match[2]
329
+
tuple_from_line(line)
330
+
for line in lines.split("\n")
331
+
if line_pattern.match(line)
334
+
def systemctl(self, q, user=None):
335
+
if user is not None:
336
+
q = q.replace("'", "\\'")
337
+
return self.execute(
340
+
"$'XDG_RUNTIME_DIR=/run/user/`id -u` "
341
+
"systemctl --user {}'"
344
+
return self.execute("systemctl {}".format(q))
346
+
def execute(self, command):
349
+
out_command = "( {} ); echo '|!EOF' $?\n".format(command)
350
+
self.shell.send(out_command.encode())
353
+
status_code_pattern = re.compile(r"(.*)\|\!EOF\s+(\d+)")
356
+
chunk = self.shell.recv(4096).decode()
357
+
match = status_code_pattern.match(chunk)
360
+
status_code = int(match[2])
361
+
return (status_code, output)
364
+
def succeed(self, *commands):
365
+
"""Execute each command and check that it succeeds."""
366
+
for command in commands:
367
+
with self.nested("must succeed: {}".format(command)):
368
+
status, output = self.execute(command)
370
+
self.log("output: {}".format(output))
372
+
"command `{}` failed (exit code {})".format(command, status)
376
+
def fail(self, *commands):
377
+
"""Execute each command and check that it fails."""
378
+
for command in commands:
379
+
with self.nested("must fail: {}".format(command)):
380
+
status, output = self.execute(command)
383
+
"command `{}` unexpectedly succeeded".format(command)
386
+
def wait_until_succeeds(self, command):
387
+
with self.nested("waiting for success: {}".format(command)):
389
+
status, output = self.execute(command)
393
+
def wait_until_fails(self, command):
394
+
with self.nested("waiting for failure: {}".format(command)):
396
+
status, output = self.execute(command)
400
+
def wait_for_shutdown(self):
401
+
if not self.booted:
404
+
with self.nested("waiting for the VM to power off"):
406
+
self.process.wait()
409
+
self.booted = False
410
+
self.connected = False
412
+
def get_tty_text(self, tty):
413
+
status, output = self.execute(
414
+
"fold -w$(stty -F /dev/tty{0} size | "
415
+
"awk '{{print $2}}') /dev/vcs{0}".format(tty)
419
+
def wait_until_tty_matches(self, tty, regexp):
420
+
matcher = re.compile(regexp)
421
+
with self.nested("waiting for {} to appear on tty {}".format(regexp, tty)):
423
+
text = self.get_tty_text(tty)
424
+
if len(matcher.findall(text)) > 0:
427
+
def send_chars(self, chars):
428
+
with self.nested("sending keys ‘{}‘".format(chars)):
430
+
self.send_key(char)
432
+
def wait_for_file(self, filename):
433
+
with self.nested("waiting for file ‘{}‘".format(filename)):
435
+
status, _ = self.execute("test -e {}".format(filename))
439
+
def wait_for_open_port(self, port):
440
+
def port_is_open(_):
441
+
status, _ = self.execute("nc -z localhost {}".format(port))
444
+
with self.nested("waiting for TCP port {}".format(port)):
445
+
retry(port_is_open)
447
+
def wait_for_closed_port(self, port):
448
+
def port_is_closed(_):
449
+
status, _ = self.execute("nc -z localhost {}".format(port))
452
+
retry(port_is_closed)
454
+
def start_job(self, jobname, user=None):
455
+
return self.systemctl("start {}".format(jobname), user)
457
+
def stop_job(self, jobname, user=None):
458
+
return self.systemctl("stop {}".format(jobname), user)
460
+
def wait_for_job(self, jobname):
461
+
return self.wait_for_unit(jobname)
467
+
with self.nested("waiting for the VM to finish booting"):
471
+
self.shell.recv(1024)
475
+
self.log("connected to guest root shell")
476
+
self.log("(connecting took {:.2f} seconds)".format(toc - tic))
477
+
self.connected = True
479
+
def screenshot(self, filename):
480
+
out_dir = os.environ.get("out", os.getcwd())
481
+
word_pattern = re.compile(r"^\w+$")
482
+
if word_pattern.match(filename):
483
+
filename = os.path.join(out_dir, "{}.png".format(filename))
484
+
tmp = "{}.ppm".format(filename)
487
+
"making screenshot {}".format(filename),
488
+
{"image": os.path.basename(filename)},
490
+
self.send_monitor_command("screendump {}".format(tmp))
491
+
ret = subprocess.run("pnmtopng {} > {}".format(tmp, filename), shell=True)
493
+
if ret.returncode != 0:
494
+
raise Exception("Cannot convert screenshot")
496
+
def get_screen_text(self):
497
+
if shutil.which("tesseract") is None:
498
+
raise Exception("get_screen_text used but enableOCR is false")
501
+
"-filter Catrom -density 72 -resample 300 "
502
+
+ "-contrast -normalize -despeckle -type grayscale "
503
+
+ "-sharpen 1 -posterize 3 -negate -gamma 100 "
507
+
tess_args = "-c debug_file=/dev/null --psm 11 --oem 2"
509
+
with self.nested("performing optical character recognition"):
510
+
with tempfile.NamedTemporaryFile() as tmpin:
511
+
self.send_monitor_command("screendump {}".format(tmpin.name))
513
+
cmd = "convert {} {} tiff:- | tesseract - - {}".format(
514
+
magick_args, tmpin.name, tess_args
516
+
ret = subprocess.run(cmd, shell=True, capture_output=True)
517
+
if ret.returncode != 0:
519
+
"OCR failed with exit code {}".format(ret.returncode)
522
+
return ret.stdout.decode("utf-8")
524
+
def wait_for_text(self, regex):
525
+
def screen_matches(last):
526
+
text = self.get_screen_text()
527
+
m = re.search(regex, text)
530
+
self.log("Last OCR attempt failed. Text was: {}".format(text))
534
+
with self.nested("waiting for {} to appear on screen".format(regex)):
535
+
retry(screen_matches)
537
+
def send_key(self, key):
538
+
key = CHAR_TO_KEY.get(key, key)
539
+
self.send_monitor_command("sendkey {}".format(key))
545
+
self.log("starting vm")
547
+
def create_socket(path):
548
+
if os.path.exists(path):
550
+
s = socket.socket(family=socket.AF_UNIX, type=socket.SOCK_STREAM)
555
+
monitor_path = os.path.join(self.state_dir, "monitor")
556
+
self.monitor_socket = create_socket(monitor_path)
558
+
shell_path = os.path.join(self.state_dir, "shell")
559
+
self.shell_socket = create_socket(shell_path)
564
+
"" if self.allow_reboot else "-no-reboot",
565
+
"-monitor unix:{}".format(monitor_path),
566
+
"-chardev socket,id=shell,path={}".format(shell_path),
567
+
"-device virtio-serial",
568
+
"-device virtconsole,chardev=shell",
569
+
"-device virtio-rng-pci",
570
+
"-serial stdio" if "DISPLAY" in os.environ else "-nographic",
574
+
+ os.environ.get("QEMU_OPTS", "")
578
+
"QEMU_OPTS": qemu_options,
579
+
"SHARED_DIR": self.shared_dir,
582
+
environment.update(dict(os.environ))
584
+
self.process = subprocess.Popen(
587
+
stdin=subprocess.DEVNULL,
588
+
stdout=subprocess.PIPE,
589
+
stderr=subprocess.STDOUT,
591
+
cwd=self.state_dir,
594
+
self.monitor, _ = self.monitor_socket.accept()
595
+
self.shell, _ = self.shell_socket.accept()
597
+
def process_serial_output():
598
+
for line in self.process.stdout:
599
+
line = line.decode().replace("\r", "").rstrip()
600
+
eprint("{} # {}".format(self.name, line))
601
+
self.logger.enqueue({"msg": line, "machine": self.name})
603
+
_thread.start_new_thread(process_serial_output, ())
605
+
self.wait_for_monitor_prompt()
607
+
self.pid = self.process.pid
610
+
self.log("QEMU running (pid {})".format(self.pid))
612
+
def shutdown(self):
616
+
self.shell.send("poweroff\n".encode())
617
+
self.wait_for_shutdown()
623
+
self.log("forced crash")
624
+
self.send_monitor_command("quit")
625
+
self.wait_for_shutdown()
627
+
def wait_for_x(self):
628
+
"""Wait until it is possible to connect to the X server. Note that
629
+
testing the existence of /tmp/.X11-unix/X0 is insufficient.
631
+
with self.nested("waiting for the X11 server"):
634
+
"journalctl -b SYSLOG_IDENTIFIER=systemd | "
635
+
+ 'grep "Reached target Current graphical"'
637
+
status, _ = self.execute(cmd)
640
+
status, _ = self.execute("[ -e /tmp/.X11-unix/X0 ]")
644
+
def sleep(self, secs):
648
+
"""Make the machine unreachable by shutting down eth1 (the multicast
649
+
interface used to talk to the other VMs). We keep eth0 up so that
650
+
the test driver can continue to talk to the machine.
652
+
self.send_monitor_command("set_link virtio-net-pci.1 off")
655
+
"""Make the machine reachable.
657
+
self.send_monitor_command("set_link virtio-net-pci.1 on")
660
+
def create_machine(args):
663
+
args["redirectSerial"] = os.environ.get("USE_SERIAL", "0") == "1"
664
+
return Machine(args)
668
+
with log.nested("starting all VMs"):
669
+
for machine in machines:
674
+
with log.nested("waiting for all VMs to finish"):
675
+
for machine in machines:
676
+
machine.wait_for_shutdown()
680
+
exec(os.environ["testScript"])
684
+
tests = os.environ.get("tests", None)
685
+
if tests is not None:
686
+
with log.nested("running the VM test script"):
689
+
except Exception as e:
690
+
eprint("error: {}".format(str(e)))
695
+
value = input("> ")
700
+
# TODO: Collect coverage data
702
+
for machine in machines:
703
+
if machine.is_up():
704
+
machine.execute("sync")
707
+
log.log("{} out of {} tests succeeded".format(nr_succeeded, nr_tests))
713
+
global nr_succeeded
715
+
with log.nested(name):
721
+
except Exception as e:
722
+
log.log("error: {}".format(str(e)))
727
+
if __name__ == "__main__":
731
+
vlan_nrs = list(dict.fromkeys(os.environ["VLANS"].split()))
732
+
vde_sockets = [create_vlan(v) for v in vlan_nrs]
733
+
for nr, vde_socket, _, _ in vde_sockets:
734
+
os.environ["QEMU_VDE_SOCKET_{}".format(nr)] = vde_socket
736
+
vm_scripts = sys.argv[1:]
737
+
machines = [create_machine({"startCommand": s}) for s in vm_scripts]
739
+
"{0} = machines[{1}]".format(m.name, idx) for idx, m in enumerate(machines)
741
+
exec("\n".join(machine_eval))
748
+
with log.nested("cleaning up"):
749
+
for machine in machines:
750
+
if machine.pid is None:
752
+
log.log("killing {} (pid {})".format(machine.name, machine.pid))
753
+
machine.process.kill()
755
+
for _, _, process, _ in vde_sockets:
762
+
print("test script finished in {:.2f}s".format(toc - tic))