lol
at 18.09-beta 722 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 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;