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 ($showGraphics ? "-serial stdio" : "-nographic") . " " . ($ENV{QEMU_OPTS} || "");
150 chdir $self->{stateDir} or die;
151 exec $self->{startCommand};
152 die "running VM script: $!";
153 }
154
155 # Process serial line output.
156 close $serialC;
157
158 threads->create(\&processSerialOutput, $self, $serialP)->detach;
159
160 sub processSerialOutput {
161 my ($self, $serialP) = @_;
162 while (<$serialP>) {
163 chomp;
164 s/\r$//;
165 print STDERR $self->{name}, "# $_\n";
166 $self->{log}->{logQueue}->enqueue({msg => $_, machine => $self->{name}}); # !!!
167 }
168 }
169
170 eval {
171 local $SIG{CHLD} = sub { die "QEMU died prematurely\n"; };
172
173 # Wait until QEMU connects to the monitor.
174 accept($self->{monitor}, $monitorS) or die;
175
176 # Wait until QEMU connects to the root shell socket. QEMU
177 # does so immediately; this doesn't mean that the root shell
178 # has connected yet inside the guest.
179 accept($self->{socket}, $shellS) or die;
180 $self->{socket}->autoflush(1);
181 };
182 die "$@" if $@;
183
184 $self->waitForMonitorPrompt;
185
186 $self->log("QEMU running (pid $pid)");
187
188 $self->{pid} = $pid;
189 $self->{booted} = 1;
190}
191
192
193# Send a command to the monitor and wait for it to finish. TODO: QEMU
194# also has a JSON-based monitor interface now, but it doesn't support
195# all commands yet. We should use it once it does.
196sub sendMonitorCommand {
197 my ($self, $command) = @_;
198 $self->log("sending monitor command: $command");
199 syswrite $self->{monitor}, "$command\n";
200 return $self->waitForMonitorPrompt;
201}
202
203
204# Wait until the monitor sends "(qemu) ".
205sub waitForMonitorPrompt {
206 my ($self) = @_;
207 my $res = "";
208 my $s;
209 while (sysread($self->{monitor}, $s, 1024)) {
210 $res .= $s;
211 last if $res =~ s/\(qemu\) $//;
212 }
213 return $res;
214}
215
216
217# Call the given code reference repeatedly, with 1 second intervals,
218# until it returns 1 or a timeout is reached.
219sub retry {
220 my ($coderef) = @_;
221 my $n;
222 for ($n = 0; $n < 900; $n++) {
223 return if &$coderef;
224 sleep 1;
225 }
226 die "action timed out after $n seconds";
227}
228
229
230sub connect {
231 my ($self) = @_;
232 return if $self->{connected};
233
234 $self->nest("waiting for the VM to finish booting", sub {
235
236 $self->start;
237
238 local $SIG{ALRM} = sub { die "timed out waiting for the VM to connect\n"; };
239 alarm 300;
240 readline $self->{socket} or die "the VM quit before connecting\n";
241 alarm 0;
242
243 $self->log("connected to guest root shell");
244 $self->{connected} = 1;
245
246 });
247}
248
249
250sub waitForShutdown {
251 my ($self) = @_;
252 return unless $self->{booted};
253
254 $self->nest("waiting for the VM to power off", sub {
255 waitpid $self->{pid}, 0;
256 $self->{pid} = 0;
257 $self->{booted} = 0;
258 $self->{connected} = 0;
259 });
260}
261
262
263sub isUp {
264 my ($self) = @_;
265 return $self->{booted} && $self->{connected};
266}
267
268
269sub execute_ {
270 my ($self, $command) = @_;
271
272 $self->connect;
273
274 print { $self->{socket} } ("( $command ); echo '|!=EOF' \$?\n");
275
276 my $out = "";
277
278 while (1) {
279 my $line = readline($self->{socket});
280 die "connection to VM lost unexpectedly" unless defined $line;
281 #$self->log("got line: $line");
282 if ($line =~ /^(.*)\|\!\=EOF\s+(\d+)$/) {
283 $out .= $1;
284 $self->log("exit status $2");
285 return ($2, $out);
286 }
287 $out .= $line;
288 }
289}
290
291
292sub execute {
293 my ($self, $command) = @_;
294 my @res;
295 $self->nest("running command: $command", sub {
296 @res = $self->execute_($command);
297 });
298 return @res;
299}
300
301
302sub succeed {
303 my ($self, @commands) = @_;
304
305 my $res;
306 foreach my $command (@commands) {
307 $self->nest("must succeed: $command", sub {
308 my ($status, $out) = $self->execute_($command);
309 if ($status != 0) {
310 $self->log("output: $out");
311 die "command `$command' did not succeed (exit code $status)\n";
312 }
313 $res .= $out;
314 });
315 }
316
317 return $res;
318}
319
320
321sub mustSucceed {
322 succeed @_;
323}
324
325
326sub waitUntilSucceeds {
327 my ($self, $command) = @_;
328 $self->nest("waiting for success: $command", sub {
329 retry sub {
330 my ($status, $out) = $self->execute($command);
331 return 1 if $status == 0;
332 };
333 });
334}
335
336
337sub waitUntilFails {
338 my ($self, $command) = @_;
339 $self->nest("waiting for failure: $command", sub {
340 retry sub {
341 my ($status, $out) = $self->execute($command);
342 return 1 if $status != 0;
343 };
344 });
345}
346
347
348sub fail {
349 my ($self, $command) = @_;
350 $self->nest("must fail: $command", sub {
351 my ($status, $out) = $self->execute_($command);
352 die "command `$command' unexpectedly succeeded"
353 if $status == 0;
354 });
355}
356
357
358sub mustFail {
359 fail @_;
360}
361
362
363sub getUnitInfo {
364 my ($self, $unit) = @_;
365 my ($status, $lines) = $self->execute("systemctl --no-pager show '$unit'");
366 return undef if $status != 0;
367 my $info = {};
368 foreach my $line (split '\n', $lines) {
369 $line =~ /^([^=]+)=(.*)$/ or next;
370 $info->{$1} = $2;
371 }
372 return $info;
373}
374
375
376# Wait for a systemd unit to reach the "active" state.
377sub waitForUnit {
378 my ($self, $unit) = @_;
379 $self->nest("waiting for unit ‘$unit’", sub {
380 retry sub {
381 my $info = $self->getUnitInfo($unit);
382 my $state = $info->{ActiveState};
383 die "unit ‘$unit’ reached state ‘$state’\n" if $state eq "failed";
384 if ($state eq "inactive") {
385 # If there are no pending jobs, then assume this unit
386 # will never reach active state.
387 my ($status, $jobs) = $self->execute("systemctl list-jobs --full 2>&1");
388 if ($jobs =~ /No jobs/) { # FIXME: fragile
389 # Handle the case where the unit may have started
390 # between the previous getUnitInfo() and
391 # list-jobs.
392 my $info2 = $self->getUnitInfo($unit);
393 die "unit ‘$unit’ is inactive and there are no pending jobs\n"
394 if $info2->{ActiveState} eq $state;
395 }
396 }
397 return 1 if $state eq "active";
398 };
399 });
400}
401
402
403sub waitForJob {
404 my ($self, $jobName) = @_;
405 return $self->waitForUnit($jobName);
406}
407
408
409# Wait until the specified file exists.
410sub waitForFile {
411 my ($self, $fileName) = @_;
412 $self->nest("waiting for file ‘$fileName’", sub {
413 retry sub {
414 my ($status, $out) = $self->execute("test -e $fileName");
415 return 1 if $status == 0;
416 }
417 });
418}
419
420sub startJob {
421 my ($self, $jobName) = @_;
422 $self->execute("systemctl start $jobName");
423 # FIXME: check result
424}
425
426sub stopJob {
427 my ($self, $jobName) = @_;
428 $self->execute("systemctl stop $jobName");
429}
430
431
432# Wait until the machine is listening on the given TCP port.
433sub waitForOpenPort {
434 my ($self, $port) = @_;
435 $self->nest("waiting for TCP port $port", sub {
436 retry sub {
437 my ($status, $out) = $self->execute("nc -z localhost $port");
438 return 1 if $status == 0;
439 }
440 });
441}
442
443
444# Wait until the machine is not listening on the given TCP port.
445sub waitForClosedPort {
446 my ($self, $port) = @_;
447 retry sub {
448 my ($status, $out) = $self->execute("nc -z localhost $port");
449 return 1 if $status != 0;
450 }
451}
452
453
454sub shutdown {
455 my ($self) = @_;
456 return unless $self->{booted};
457
458 print { $self->{socket} } ("poweroff\n");
459
460 $self->waitForShutdown;
461}
462
463
464sub crash {
465 my ($self) = @_;
466 return unless $self->{booted};
467
468 $self->log("forced crash");
469
470 $self->sendMonitorCommand("quit");
471
472 $self->waitForShutdown;
473}
474
475
476# Make the machine unreachable by shutting down eth1 (the multicast
477# interface used to talk to the other VMs). We keep eth0 up so that
478# the test driver can continue to talk to the machine.
479sub block {
480 my ($self) = @_;
481 $self->sendMonitorCommand("set_link virtio-net-pci.1 off");
482}
483
484
485# Make the machine reachable.
486sub unblock {
487 my ($self) = @_;
488 $self->sendMonitorCommand("set_link virtio-net-pci.1 on");
489}
490
491
492# Take a screenshot of the X server on :0.0.
493sub screenshot {
494 my ($self, $filename) = @_;
495 my $dir = $ENV{'out'} || Cwd::abs_path(".");
496 $filename = "$dir/${filename}.png" if $filename =~ /^\w+$/;
497 my $tmp = "${filename}.ppm";
498 my $name = basename($filename);
499 $self->nest("making screenshot ‘$name’", sub {
500 $self->sendMonitorCommand("screendump $tmp");
501 system("pnmtopng $tmp > ${filename}") == 0
502 or die "cannot convert screenshot";
503 unlink $tmp;
504 }, { image => $name } );
505}
506
507
508# Take a screenshot and return the result as text using optical character
509# recognition.
510sub getScreenText {
511 my ($self) = @_;
512
513 system("command -v tesseract &> /dev/null") == 0
514 or die "getScreenText used but enableOCR is false";
515
516 my $text;
517 $self->nest("performing optical character recognition", sub {
518 my $tmpbase = Cwd::abs_path(".")."/ocr";
519 my $tmpin = $tmpbase."in.ppm";
520 my $tmpout = "$tmpbase.ppm";
521
522 $self->sendMonitorCommand("screendump $tmpin");
523 system("ppmtopgm $tmpin | pamscale 4 -filter=lanczos > $tmpout") == 0
524 or die "cannot scale screenshot";
525 unlink $tmpin;
526 system("tesseract $tmpout $tmpbase") == 0 or die "OCR failed";
527 unlink $tmpout;
528 $text = read_file("$tmpbase.txt");
529 unlink "$tmpbase.txt";
530 });
531 return $text;
532}
533
534
535# Wait until a specific regexp matches the textual contents of the screen.
536sub waitForText {
537 my ($self, $regexp) = @_;
538 $self->nest("waiting for $regexp to appear on the screen", sub {
539 retry sub {
540 return 1 if $self->getScreenText =~ /$regexp/;
541 }
542 });
543}
544
545
546# Wait until it is possible to connect to the X server. Note that
547# testing the existence of /tmp/.X11-unix/X0 is insufficient.
548sub waitForX {
549 my ($self, $regexp) = @_;
550 $self->nest("waiting for the X11 server", sub {
551 retry sub {
552 my ($status, $out) = $self->execute("journalctl -b SYSLOG_IDENTIFIER=systemd | grep 'session opened'");
553 return 0 if $status != 0;
554 ($status, $out) = $self->execute("[ -e /tmp/.X11-unix/X0 ]");
555 return 1 if $status == 0;
556 }
557 });
558}
559
560
561sub getWindowNames {
562 my ($self) = @_;
563 my $res = $self->mustSucceed(
564 q{xwininfo -root -tree | sed 's/.*0x[0-9a-f]* \"\([^\"]*\)\".*/\1/; t; d'});
565 return split /\n/, $res;
566}
567
568
569sub waitForWindow {
570 my ($self, $regexp) = @_;
571 $self->nest("waiting for a window to appear", sub {
572 retry sub {
573 my @names = $self->getWindowNames;
574 foreach my $n (@names) {
575 return 1 if $n =~ /$regexp/;
576 }
577 }
578 });
579}
580
581
582sub copyFileFromHost {
583 my ($self, $from, $to) = @_;
584 my $s = `cat $from` or die;
585 $self->mustSucceed("echo '$s' > $to"); # !!! escaping
586}
587
588
589sub sendKeys {
590 my ($self, @keys) = @_;
591 foreach my $key (@keys) {
592 $key = "spc" if $key eq " ";
593 $key = "ret" if $key eq "\n";
594 $self->sendMonitorCommand("sendkey $key");
595 }
596}
597
598
599sub sendChars {
600 my ($self, $chars) = @_;
601 $self->nest("sending keys ‘$chars’", sub {
602 $self->sendKeys(split //, $chars);
603 });
604}
605
606
607# Sleep N seconds (in virtual guest time, not real time).
608sub sleep {
609 my ($self, $time) = @_;
610 $self->succeed("sleep $time");
611}
612
613
614# Forward a TCP port on the host to a TCP port on the guest. Useful
615# during interactive testing.
616sub forwardPort {
617 my ($self, $hostPort, $guestPort) = @_;
618 $hostPort = 8080 unless defined $hostPort;
619 $guestPort = 80 unless defined $guestPort;
620 $self->sendMonitorCommand("hostfwd_add tcp::$hostPort-:$guestPort");
621}
622
623
6241;