lol
1package Machine;
2
3use strict;
4use threads;
5use Socket;
6use IO::Handle;
7use POSIX qw(dup2);
8use FileHandle;
9use Cwd;
10use File::Basename;
11use File::Path qw(make_path);
12use File::Slurp;
13
14
15my $showGraphics = defined $ENV{'DISPLAY'};
16
17my $sharedDir;
18
19
20sub new {
21 my ($class, $args) = @_;
22
23 my $startCommand = $args->{startCommand};
24
25 my $name = $args->{name};
26 if (!$name) {
27 $startCommand =~ /run-(.*)-vm$/ if defined $startCommand;
28 $name = $1 || "machine";
29 }
30
31 if (!$startCommand) {
32 # !!! merge with qemu-vm.nix.
33 $startCommand =
34 "qemu-kvm -m 384 " .
35 "-net nic,model=virtio \$QEMU_OPTS ";
36
37 if (defined $args->{hda}) {
38 if ($args->{hdaInterface} eq "scsi") {
39 $startCommand .= "-drive id=hda,file="
40 . Cwd::abs_path($args->{hda})
41 . ",werror=report,if=none "
42 . "-device scsi-hd,drive=hda ";
43 } else {
44 $startCommand .= "-drive file=" . Cwd::abs_path($args->{hda})
45 . ",if=" . $args->{hdaInterface}
46 . ",werror=report ";
47 }
48 }
49
50 $startCommand .= "-cdrom $args->{cdrom} "
51 if defined $args->{cdrom};
52 $startCommand .= "-device piix3-usb-uhci -drive id=usbdisk,file=$args->{usb},if=none,readonly -device usb-storage,drive=usbdisk "
53 if defined $args->{usb};
54 $startCommand .= "-bios $args->{bios} "
55 if defined $args->{bios};
56 $startCommand .= $args->{qemuFlags} || "";
57 }
58
59 my $tmpDir = $ENV{'TMPDIR'} || "/tmp";
60 unless (defined $sharedDir) {
61 $sharedDir = $tmpDir . "/xchg-shared";
62 make_path($sharedDir, { mode => 0700, owner => $< });
63 }
64
65 my $allowReboot = 0;
66 $allowReboot = $args->{allowReboot} if defined $args->{allowReboot};
67
68 my $self = {
69 startCommand => $startCommand,
70 name => $name,
71 allowReboot => $allowReboot,
72 booted => 0,
73 pid => 0,
74 connected => 0,
75 socket => undef,
76 stateDir => "$tmpDir/vm-state-$name",
77 monitor => undef,
78 log => $args->{log},
79 redirectSerial => $args->{redirectSerial} // 1,
80 };
81
82 mkdir $self->{stateDir}, 0700;
83
84 bless $self, $class;
85 return $self;
86}
87
88
89sub log {
90 my ($self, $msg) = @_;
91 $self->{log}->log($msg, { machine => $self->{name} });
92}
93
94
95sub nest {
96 my ($self, $msg, $coderef, $attrs) = @_;
97 $self->{log}->nest($msg, $coderef, { %{$attrs || {}}, machine => $self->{name} });
98}
99
100
101sub name {
102 my ($self) = @_;
103 return $self->{name};
104}
105
106
107sub stateDir {
108 my ($self) = @_;
109 return $self->{stateDir};
110}
111
112
113sub start {
114 my ($self) = @_;
115 return if $self->{booted};
116
117 $self->log("starting vm");
118
119 # Create a socket pair for the serial line input/output of the VM.
120 my ($serialP, $serialC);
121 socketpair($serialP, $serialC, PF_UNIX, SOCK_STREAM, 0) or die;
122
123 # Create a Unix domain socket to which QEMU's monitor will connect.
124 my $monitorPath = $self->{stateDir} . "/monitor";
125 unlink $monitorPath;
126 my $monitorS;
127 socket($monitorS, PF_UNIX, SOCK_STREAM, 0) or die;
128 bind($monitorS, sockaddr_un($monitorPath)) or die "cannot bind monitor socket: $!";
129 listen($monitorS, 1) or die;
130
131 # Create a Unix domain socket to which the root shell in the guest will connect.
132 my $shellPath = $self->{stateDir} . "/shell";
133 unlink $shellPath;
134 my $shellS;
135 socket($shellS, PF_UNIX, SOCK_STREAM, 0) or die;
136 bind($shellS, sockaddr_un($shellPath)) or die "cannot bind shell socket: $!";
137 listen($shellS, 1) or die;
138
139 # Start the VM.
140 my $pid = fork();
141 die if $pid == -1;
142
143 if ($pid == 0) {
144 close $serialP;
145 close $monitorS;
146 close $shellS;
147 if ($self->{redirectSerial}) {
148 open NUL, "</dev/null" or die;
149 dup2(fileno(NUL), fileno(STDIN));
150 dup2(fileno($serialC), fileno(STDOUT));
151 dup2(fileno($serialC), fileno(STDERR));
152 }
153 $ENV{TMPDIR} = $self->{stateDir};
154 $ENV{SHARED_DIR} = $sharedDir;
155 $ENV{USE_TMPDIR} = 1;
156 $ENV{QEMU_OPTS} =
157 ($self->{allowReboot} ? "" : "-no-reboot ") .
158 "-monitor unix:./monitor -chardev socket,id=shell,path=./shell " .
159 "-device virtio-serial -device virtconsole,chardev=shell " .
160 "-device virtio-rng-pci " .
161 ($showGraphics ? "-serial stdio" : "-nographic") . " " . ($ENV{QEMU_OPTS} || "");
162 chdir $self->{stateDir} or die;
163 exec $self->{startCommand};
164 die "running VM script: $!";
165 }
166
167 # Process serial line output.
168 close $serialC;
169
170 threads->create(\&processSerialOutput, $self, $serialP)->detach;
171
172 sub processSerialOutput {
173 my ($self, $serialP) = @_;
174 while (<$serialP>) {
175 chomp;
176 s/\r$//;
177 print STDERR $self->{name}, "# $_\n";
178 $self->{log}->{logQueue}->enqueue({msg => $_, machine => $self->{name}}); # !!!
179 }
180 }
181
182 eval {
183 local $SIG{CHLD} = sub { die "QEMU died prematurely\n"; };
184
185 # Wait until QEMU connects to the monitor.
186 accept($self->{monitor}, $monitorS) or die;
187
188 # Wait until QEMU connects to the root shell socket. QEMU
189 # does so immediately; this doesn't mean that the root shell
190 # has connected yet inside the guest.
191 accept($self->{socket}, $shellS) or die;
192 $self->{socket}->autoflush(1);
193 };
194 die "$@" if $@;
195
196 $self->waitForMonitorPrompt;
197
198 $self->log("QEMU running (pid $pid)");
199
200 $self->{pid} = $pid;
201 $self->{booted} = 1;
202}
203
204
205# Send a command to the monitor and wait for it to finish. TODO: QEMU
206# also has a JSON-based monitor interface now, but it doesn't support
207# all commands yet. We should use it once it does.
208sub sendMonitorCommand {
209 my ($self, $command) = @_;
210 $self->log("sending monitor command: $command");
211 syswrite $self->{monitor}, "$command\n";
212 return $self->waitForMonitorPrompt;
213}
214
215
216# Wait until the monitor sends "(qemu) ".
217sub waitForMonitorPrompt {
218 my ($self) = @_;
219 my $res = "";
220 my $s;
221 while (sysread($self->{monitor}, $s, 1024)) {
222 $res .= $s;
223 last if $res =~ s/\(qemu\) $//;
224 }
225 return $res;
226}
227
228
229# Call the given code reference repeatedly, with 1 second intervals,
230# until it returns 1 or a timeout is reached.
231sub retry {
232 my ($coderef) = @_;
233 my $n;
234 for ($n = 899; $n >=0; $n--) {
235 return if &$coderef($n);
236 sleep 1;
237 }
238 die "action timed out after $n seconds";
239}
240
241
242sub connect {
243 my ($self) = @_;
244 return if $self->{connected};
245
246 $self->nest("waiting for the VM to finish booting", sub {
247
248 $self->start;
249
250 local $SIG{ALRM} = sub { die "timed out waiting for the VM to connect\n"; };
251 alarm 300;
252 readline $self->{socket} or die "the VM quit before connecting\n";
253 alarm 0;
254
255 $self->log("connected to guest root shell");
256 $self->{connected} = 1;
257
258 });
259}
260
261
262sub waitForShutdown {
263 my ($self) = @_;
264 return unless $self->{booted};
265
266 $self->nest("waiting for the VM to power off", sub {
267 waitpid $self->{pid}, 0;
268 $self->{pid} = 0;
269 $self->{booted} = 0;
270 $self->{connected} = 0;
271 });
272}
273
274
275sub isUp {
276 my ($self) = @_;
277 return $self->{booted} && $self->{connected};
278}
279
280
281sub execute_ {
282 my ($self, $command) = @_;
283
284 $self->connect;
285
286 print { $self->{socket} } ("( $command ); echo '|!=EOF' \$?\n");
287
288 my $out = "";
289
290 while (1) {
291 my $line = readline($self->{socket});
292 die "connection to VM lost unexpectedly" unless defined $line;
293 #$self->log("got line: $line");
294 if ($line =~ /^(.*)\|\!\=EOF\s+(\d+)$/) {
295 $out .= $1;
296 $self->log("exit status $2");
297 return ($2, $out);
298 }
299 $out .= $line;
300 }
301}
302
303
304sub execute {
305 my ($self, $command) = @_;
306 my @res;
307 $self->nest("running command: $command", sub {
308 @res = $self->execute_($command);
309 });
310 return @res;
311}
312
313
314sub succeed {
315 my ($self, @commands) = @_;
316
317 my $res;
318 foreach my $command (@commands) {
319 $self->nest("must succeed: $command", sub {
320 my ($status, $out) = $self->execute_($command);
321 if ($status != 0) {
322 $self->log("output: $out");
323 die "command `$command' did not succeed (exit code $status)\n";
324 }
325 $res .= $out;
326 });
327 }
328
329 return $res;
330}
331
332
333sub mustSucceed {
334 succeed @_;
335}
336
337
338sub waitUntilSucceeds {
339 my ($self, $command) = @_;
340 $self->nest("waiting for success: $command", sub {
341 retry sub {
342 my ($status, $out) = $self->execute($command);
343 return 1 if $status == 0;
344 };
345 });
346}
347
348
349sub waitUntilFails {
350 my ($self, $command) = @_;
351 $self->nest("waiting for failure: $command", sub {
352 retry sub {
353 my ($status, $out) = $self->execute($command);
354 return 1 if $status != 0;
355 };
356 });
357}
358
359
360sub fail {
361 my ($self, $command) = @_;
362 $self->nest("must fail: $command", sub {
363 my ($status, $out) = $self->execute_($command);
364 die "command `$command' unexpectedly succeeded"
365 if $status == 0;
366 });
367}
368
369
370sub mustFail {
371 fail @_;
372}
373
374
375sub getUnitInfo {
376 my ($self, $unit, $user) = @_;
377 my ($status, $lines) = $self->systemctl("--no-pager show \"$unit\"", $user);
378 return undef if $status != 0;
379 my $info = {};
380 foreach my $line (split '\n', $lines) {
381 $line =~ /^([^=]+)=(.*)$/ or next;
382 $info->{$1} = $2;
383 }
384 return $info;
385}
386
387sub systemctl {
388 my ($self, $q, $user) = @_;
389 if ($user) {
390 $q =~ s/'/\\'/g;
391 return $self->execute("su -l $user -c \$'XDG_RUNTIME_DIR=/run/user/`id -u` systemctl --user $q'");
392 }
393
394 return $self->execute("systemctl $q");
395}
396
397# Fail if the given systemd unit is not in the "active" state.
398sub requireActiveUnit {
399 my ($self, $unit) = @_;
400 $self->nest("checking if unit ‘$unit’ has reached state 'active'", sub {
401 my $info = $self->getUnitInfo($unit);
402 my $state = $info->{ActiveState};
403 if ($state ne "active") {
404 die "Expected unit ‘$unit’ to to be in state 'active' but it is in state ‘$state’\n";
405 };
406 });
407}
408
409# Wait for a systemd unit to reach the "active" state.
410sub waitForUnit {
411 my ($self, $unit, $user) = @_;
412 $self->nest("waiting for unit ‘$unit’", sub {
413 retry sub {
414 my $info = $self->getUnitInfo($unit, $user);
415 my $state = $info->{ActiveState};
416 die "unit ‘$unit’ reached state ‘$state’\n" if $state eq "failed";
417 if ($state eq "inactive") {
418 # If there are no pending jobs, then assume this unit
419 # will never reach active state.
420 my ($status, $jobs) = $self->systemctl("list-jobs --full 2>&1", $user);
421 if ($jobs =~ /No jobs/) { # FIXME: fragile
422 # Handle the case where the unit may have started
423 # between the previous getUnitInfo() and
424 # list-jobs.
425 my $info2 = $self->getUnitInfo($unit);
426 die "unit ‘$unit’ is inactive and there are no pending jobs\n"
427 if $info2->{ActiveState} eq $state;
428 }
429 }
430 return 1 if $state eq "active";
431 };
432 });
433}
434
435
436sub waitForJob {
437 my ($self, $jobName) = @_;
438 return $self->waitForUnit($jobName);
439}
440
441
442# Wait until the specified file exists.
443sub waitForFile {
444 my ($self, $fileName) = @_;
445 $self->nest("waiting for file ‘$fileName’", sub {
446 retry sub {
447 my ($status, $out) = $self->execute("test -e $fileName");
448 return 1 if $status == 0;
449 }
450 });
451}
452
453sub startJob {
454 my ($self, $jobName, $user) = @_;
455 $self->systemctl("start $jobName", $user);
456 # FIXME: check result
457}
458
459sub stopJob {
460 my ($self, $jobName, $user) = @_;
461 $self->systemctl("stop $jobName", $user);
462}
463
464
465# Wait until the machine is listening on the given TCP port.
466sub waitForOpenPort {
467 my ($self, $port) = @_;
468 $self->nest("waiting for TCP port $port", sub {
469 retry sub {
470 my ($status, $out) = $self->execute("nc -z localhost $port");
471 return 1 if $status == 0;
472 }
473 });
474}
475
476
477# Wait until the machine is not listening on the given TCP port.
478sub waitForClosedPort {
479 my ($self, $port) = @_;
480 retry sub {
481 my ($status, $out) = $self->execute("nc -z localhost $port");
482 return 1 if $status != 0;
483 }
484}
485
486
487sub shutdown {
488 my ($self) = @_;
489 return unless $self->{booted};
490
491 print { $self->{socket} } ("poweroff\n");
492
493 $self->waitForShutdown;
494}
495
496
497sub crash {
498 my ($self) = @_;
499 return unless $self->{booted};
500
501 $self->log("forced crash");
502
503 $self->sendMonitorCommand("quit");
504
505 $self->waitForShutdown;
506}
507
508
509# Make the machine unreachable by shutting down eth1 (the multicast
510# interface used to talk to the other VMs). We keep eth0 up so that
511# the test driver can continue to talk to the machine.
512sub block {
513 my ($self) = @_;
514 $self->sendMonitorCommand("set_link virtio-net-pci.1 off");
515}
516
517
518# Make the machine reachable.
519sub unblock {
520 my ($self) = @_;
521 $self->sendMonitorCommand("set_link virtio-net-pci.1 on");
522}
523
524
525# Take a screenshot of the X server on :0.0.
526sub screenshot {
527 my ($self, $filename) = @_;
528 my $dir = $ENV{'out'} || Cwd::abs_path(".");
529 $filename = "$dir/${filename}.png" if $filename =~ /^\w+$/;
530 my $tmp = "${filename}.ppm";
531 my $name = basename($filename);
532 $self->nest("making screenshot ‘$name’", sub {
533 $self->sendMonitorCommand("screendump $tmp");
534 system("pnmtopng $tmp > ${filename}") == 0
535 or die "cannot convert screenshot";
536 unlink $tmp;
537 }, { image => $name } );
538}
539
540# Get the text of TTY<n>
541sub getTTYText {
542 my ($self, $tty) = @_;
543
544 my ($status, $out) = $self->execute("fold -w\$(stty -F /dev/tty${tty} size | awk '{print \$2}') /dev/vcs${tty}");
545 return $out;
546}
547
548# Wait until TTY<n>'s text matches a particular regular expression
549sub waitUntilTTYMatches {
550 my ($self, $tty, $regexp) = @_;
551
552 $self->nest("waiting for $regexp to appear on tty $tty", sub {
553 retry sub {
554 my ($retries_remaining) = @_;
555 if ($retries_remaining == 0) {
556 $self->log("Last chance to match /$regexp/ on TTY$tty, which currently contains:");
557 $self->log($self->getTTYText($tty));
558 }
559
560 return 1 if $self->getTTYText($tty) =~ /$regexp/;
561 }
562 });
563}
564
565# Debugging: Dump the contents of the TTY<n>
566sub dumpTTYContents {
567 my ($self, $tty) = @_;
568
569 $self->execute("fold -w 80 /dev/vcs${tty} | systemd-cat");
570}
571
572# Take a screenshot and return the result as text using optical character
573# recognition.
574sub getScreenText {
575 my ($self) = @_;
576
577 system("command -v tesseract &> /dev/null") == 0
578 or die "getScreenText used but enableOCR is false";
579
580 my $text;
581 $self->nest("performing optical character recognition", sub {
582 my $tmpbase = Cwd::abs_path(".")."/ocr";
583 my $tmpin = $tmpbase."in.ppm";
584
585 $self->sendMonitorCommand("screendump $tmpin");
586
587 my $magickArgs = "-filter Catrom -density 72 -resample 300 "
588 . "-contrast -normalize -despeckle -type grayscale "
589 . "-sharpen 1 -posterize 3 -negate -gamma 100 "
590 . "-blur 1x65535";
591 my $tessArgs = "-c debug_file=/dev/null --psm 11 --oem 2";
592
593 $text = `convert $magickArgs $tmpin tiff:- | tesseract - - $tessArgs`;
594 my $status = $? >> 8;
595 unlink $tmpin;
596
597 die "OCR failed with exit code $status" if $status != 0;
598 });
599 return $text;
600}
601
602
603# Wait until a specific regexp matches the textual contents of the screen.
604sub waitForText {
605 my ($self, $regexp) = @_;
606 $self->nest("waiting for $regexp to appear on the screen", sub {
607 retry sub {
608 my ($retries_remaining) = @_;
609 if ($retries_remaining == 0) {
610 $self->log("Last chance to match /$regexp/ on the screen, which currently contains:");
611 $self->log($self->getScreenText);
612 }
613
614 return 1 if $self->getScreenText =~ /$regexp/;
615 }
616 });
617}
618
619
620# Wait until it is possible to connect to the X server. Note that
621# testing the existence of /tmp/.X11-unix/X0 is insufficient.
622sub waitForX {
623 my ($self, $regexp) = @_;
624 $self->nest("waiting for the X11 server", sub {
625 retry sub {
626 my ($status, $out) = $self->execute("journalctl -b SYSLOG_IDENTIFIER=systemd | grep 'Reached target Current graphical'");
627 return 0 if $status != 0;
628 ($status, $out) = $self->execute("[ -e /tmp/.X11-unix/X0 ]");
629 return 1 if $status == 0;
630 }
631 });
632}
633
634
635sub getWindowNames {
636 my ($self) = @_;
637 my $res = $self->mustSucceed(
638 q{xwininfo -root -tree | sed 's/.*0x[0-9a-f]* \"\([^\"]*\)\".*/\1/; t; d'});
639 return split /\n/, $res;
640}
641
642
643sub waitForWindow {
644 my ($self, $regexp) = @_;
645 $self->nest("waiting for a window to appear", sub {
646 retry sub {
647 my @names = $self->getWindowNames;
648
649 my ($retries_remaining) = @_;
650 if ($retries_remaining == 0) {
651 $self->log("Last chance to match /$regexp/ on the the window list, which currently contains:");
652 $self->log(join(", ", @names));
653 }
654
655 foreach my $n (@names) {
656 return 1 if $n =~ /$regexp/;
657 }
658 }
659 });
660}
661
662
663sub copyFileFromHost {
664 my ($self, $from, $to) = @_;
665 my $s = `cat $from` or die;
666 $s =~ s/'/'\\''/g;
667 $self->mustSucceed("echo '$s' > $to");
668}
669
670
671my %charToKey = (
672 'A' => "shift-a", 'N' => "shift-n", '-' => "0x0C", '_' => "shift-0x0C", '!' => "shift-0x02",
673 'B' => "shift-b", 'O' => "shift-o", '=' => "0x0D", '+' => "shift-0x0D", '@' => "shift-0x03",
674 'C' => "shift-c", 'P' => "shift-p", '[' => "0x1A", '{' => "shift-0x1A", '#' => "shift-0x04",
675 'D' => "shift-d", 'Q' => "shift-q", ']' => "0x1B", '}' => "shift-0x1B", '$' => "shift-0x05",
676 'E' => "shift-e", 'R' => "shift-r", ';' => "0x27", ':' => "shift-0x27", '%' => "shift-0x06",
677 'F' => "shift-f", 'S' => "shift-s", '\'' => "0x28", '"' => "shift-0x28", '^' => "shift-0x07",
678 'G' => "shift-g", 'T' => "shift-t", '`' => "0x29", '~' => "shift-0x29", '&' => "shift-0x08",
679 'H' => "shift-h", 'U' => "shift-u", '\\' => "0x2B", '|' => "shift-0x2B", '*' => "shift-0x09",
680 'I' => "shift-i", 'V' => "shift-v", ',' => "0x33", '<' => "shift-0x33", '(' => "shift-0x0A",
681 'J' => "shift-j", 'W' => "shift-w", '.' => "0x34", '>' => "shift-0x34", ')' => "shift-0x0B",
682 'K' => "shift-k", 'X' => "shift-x", '/' => "0x35", '?' => "shift-0x35",
683 'L' => "shift-l", 'Y' => "shift-y", ' ' => "spc",
684 'M' => "shift-m", 'Z' => "shift-z", "\n" => "ret",
685);
686
687
688sub sendKeys {
689 my ($self, @keys) = @_;
690 foreach my $key (@keys) {
691 $key = $charToKey{$key} if exists $charToKey{$key};
692 $self->sendMonitorCommand("sendkey $key");
693 }
694}
695
696
697sub sendChars {
698 my ($self, $chars) = @_;
699 $self->nest("sending keys ‘$chars’", sub {
700 $self->sendKeys(split //, $chars);
701 });
702}
703
704
705# Sleep N seconds (in virtual guest time, not real time).
706sub sleep {
707 my ($self, $time) = @_;
708 $self->succeed("sleep $time");
709}
710
711
712# Forward a TCP port on the host to a TCP port on the guest. Useful
713# during interactive testing.
714sub forwardPort {
715 my ($self, $hostPort, $guestPort) = @_;
716 $hostPort = 8080 unless defined $hostPort;
717 $guestPort = 80 unless defined $guestPort;
718 $self->sendMonitorCommand("hostfwd_add tcp::$hostPort-:$guestPort");
719}
720
721
7221;