at 18.03-beta 711 lines 20 kB view raw
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;