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