1<?php 2 3namespace FPM; 4 5use FPM\FastCGI\Client; 6use FPM\FastCGI\SocketTransport; 7use FPM\FastCGI\StreamTransport; 8use FPM\FastCGI\Transport; 9 10require_once 'fcgi.inc'; 11require_once 'logreader.inc'; 12require_once 'logtool.inc'; 13require_once 'response.inc'; 14 15class Tester 16{ 17 /** 18 * Config directory for included files. 19 */ 20 const CONF_DIR = __DIR__ . '/conf.d'; 21 22 /** 23 * File extension for access log. 24 */ 25 const FILE_EXT_LOG_ACC = 'acc.log'; 26 27 /** 28 * File extension for error log. 29 */ 30 const FILE_EXT_LOG_ERR = 'err.log'; 31 32 /** 33 * File extension for slow log. 34 */ 35 const FILE_EXT_LOG_SLOW = 'slow.log'; 36 37 /** 38 * File extension for PID file. 39 */ 40 const FILE_EXT_PID = 'pid'; 41 42 /** 43 * @var array 44 */ 45 static private array $supportedFiles = [ 46 self::FILE_EXT_LOG_ACC, 47 self::FILE_EXT_LOG_ERR, 48 self::FILE_EXT_LOG_SLOW, 49 self::FILE_EXT_PID, 50 'src.php', 51 'ini', 52 'skip.ini', 53 '*.sock', 54 ]; 55 56 /** 57 * @var array 58 */ 59 static private array $filesToClean = ['.user.ini']; 60 61 /** 62 * @var bool 63 */ 64 private bool $debug; 65 66 /** 67 * @var array 68 */ 69 private array $clients = []; 70 71 /** 72 * @var string 73 */ 74 private string $clientTransport; 75 76 /** 77 * @var LogReader 78 */ 79 private LogReader $logReader; 80 81 /** 82 * @var LogTool 83 */ 84 private LogTool $logTool; 85 86 /** 87 * Configuration template 88 * 89 * @var string|array 90 */ 91 private string|array $configTemplate; 92 93 /** 94 * The PHP code to execute 95 * 96 * @var string 97 */ 98 private string $code; 99 100 /** 101 * @var array 102 */ 103 private array $options; 104 105 /** 106 * @var string 107 */ 108 private string $fileName; 109 110 /** 111 * @var resource 112 */ 113 private $masterProcess; 114 115 /** 116 * @var bool 117 */ 118 private bool $daemonized; 119 120 /** 121 * @var resource 122 */ 123 private $outDesc; 124 125 /** 126 * @var array 127 */ 128 private array $ports = []; 129 130 /** 131 * @var string|null 132 */ 133 private ?string $error = null; 134 135 /** 136 * The last response for the request call 137 * 138 * @var Response|null 139 */ 140 private ?Response $response; 141 142 /** 143 * @var string[] 144 */ 145 private $expectedAccessLogs; 146 147 /** 148 * @var bool 149 */ 150 private $expectSuppressableAccessLogEntries; 151 152 /** 153 * Clean all the created files up 154 * 155 * @param int $backTraceIndex 156 */ 157 static public function clean($backTraceIndex = 1) 158 { 159 $filePrefix = self::getCallerFileName($backTraceIndex); 160 if (str_ends_with($filePrefix, 'clean.')) { 161 $filePrefix = substr($filePrefix, 0, -6); 162 } 163 164 $filesToClean = array_merge( 165 array_map( 166 function ($fileExtension) use ($filePrefix) { 167 return $filePrefix . $fileExtension; 168 }, 169 self::$supportedFiles 170 ), 171 array_map( 172 function ($fileExtension) { 173 return __DIR__ . '/' . $fileExtension; 174 }, 175 self::$filesToClean 176 ) 177 ); 178 // clean all the root files 179 foreach ($filesToClean as $filePattern) { 180 foreach (glob($filePattern) as $filePath) { 181 unlink($filePath); 182 } 183 } 184 185 self::cleanConfigFiles(); 186 } 187 188 /** 189 * Clean config files 190 */ 191 static public function cleanConfigFiles() 192 { 193 if (is_dir(self::CONF_DIR)) { 194 foreach (glob(self::CONF_DIR . '/*.conf') as $name) { 195 unlink($name); 196 } 197 rmdir(self::CONF_DIR); 198 } 199 } 200 201 /** 202 * @param int $backTraceIndex 203 * 204 * @return string 205 */ 206 static private function getCallerFileName(int $backTraceIndex = 1): string 207 { 208 $backtrace = debug_backtrace(); 209 if (isset($backtrace[$backTraceIndex]['file'])) { 210 $filePath = $backtrace[$backTraceIndex]['file']; 211 } else { 212 $filePath = __FILE__; 213 } 214 215 return substr($filePath, 0, -strlen(pathinfo($filePath, PATHINFO_EXTENSION))); 216 } 217 218 /** 219 * @return bool|string 220 */ 221 static public function findExecutable(): bool|string 222 { 223 $phpPath = getenv("TEST_PHP_EXECUTABLE"); 224 for ($i = 0; $i < 2; $i++) { 225 $slashPosition = strrpos($phpPath, "/"); 226 if ($slashPosition) { 227 $phpPath = substr($phpPath, 0, $slashPosition); 228 } else { 229 break; 230 } 231 } 232 233 if ($phpPath && is_dir($phpPath)) { 234 if (file_exists($phpPath . "/fpm/php-fpm") && is_executable($phpPath . "/fpm/php-fpm")) { 235 /* gotcha */ 236 return $phpPath . "/fpm/php-fpm"; 237 } 238 $phpSbinFpmi = $phpPath . "/sbin/php-fpm"; 239 if (file_exists($phpSbinFpmi) && is_executable($phpSbinFpmi)) { 240 return $phpSbinFpmi; 241 } 242 } 243 244 // try local php-fpm 245 $fpmPath = dirname(__DIR__) . '/php-fpm'; 246 if (file_exists($fpmPath) && is_executable($fpmPath)) { 247 return $fpmPath; 248 } 249 250 return false; 251 } 252 253 /** 254 * Skip test if any of the supplied files does not exist. 255 * 256 * @param mixed $files 257 */ 258 static public function skipIfAnyFileDoesNotExist($files) 259 { 260 if ( ! is_array($files)) { 261 $files = array($files); 262 } 263 foreach ($files as $file) { 264 if ( ! file_exists($file)) { 265 die("skip File $file does not exist"); 266 } 267 } 268 } 269 270 /** 271 * Skip test if config file is invalid. 272 * 273 * @param string $configTemplate 274 * 275 * @throws \Exception 276 */ 277 static public function skipIfConfigFails(string $configTemplate) 278 { 279 $tester = new self($configTemplate, '', [], self::getCallerFileName()); 280 $testResult = $tester->testConfig(true); 281 if ($testResult !== null) { 282 self::clean(2); 283 $message = $testResult[0] ?? 'Config failed'; 284 die("skip $message"); 285 } 286 } 287 288 /** 289 * Skip test if IPv6 is not supported. 290 */ 291 static public function skipIfIPv6IsNotSupported() 292 { 293 @stream_socket_client('tcp://[::1]:0', $errno); 294 if ($errno != 111) { 295 die('skip IPv6 is not supported.'); 296 } 297 } 298 299 /** 300 * Skip if not running as root. 301 */ 302 static public function skipIfNotRoot() 303 { 304 if (exec('whoami') !== 'root') { 305 die('skip not running as root'); 306 } 307 } 308 309 /** 310 * Skip if running as root. 311 */ 312 static public function skipIfRoot() 313 { 314 if (exec('whoami') === 'root') { 315 die('skip running as root'); 316 } 317 } 318 319 /** 320 * Skip if posix extension not loaded. 321 */ 322 static public function skipIfPosixNotLoaded() 323 { 324 if ( ! extension_loaded('posix')) { 325 die('skip posix extension not loaded'); 326 } 327 } 328 329 /** 330 * Skip if shared extension is not available in extension directory. 331 */ 332 static public function skipIfSharedExtensionNotFound($extensionName) 333 { 334 $soPath = ini_get('extension_dir') . '/' . $extensionName . '.so'; 335 if ( ! file_exists($soPath)) { 336 die("skip $extensionName extension not present in extension_dir"); 337 } 338 } 339 340 /** 341 * Skip test if supplied shell command fails. 342 * 343 * @param string $command 344 * @param string|null $expectedPartOfOutput 345 */ 346 static public function skipIfShellCommandFails(string $command, ?string $expectedPartOfOutput = null) 347 { 348 $result = exec("$command 2>&1", $output, $code); 349 if ($result === false || $code) { 350 die("skip command '$command' faieled with code $code"); 351 } 352 if (!is_null($expectedPartOfOutput)) { 353 if (is_array($output)) { 354 foreach ($output as $line) { 355 if (str_contains($line, $expectedPartOfOutput)) { 356 // string found so no need to skip 357 return; 358 } 359 } 360 } 361 die("skip command '$command' did not contain output '$expectedPartOfOutput'"); 362 } 363 } 364 365 /** 366 * Skip if posix extension not loaded. 367 */ 368 static public function skipIfUserDoesNotExist($userName) { 369 self::skipIfPosixNotLoaded(); 370 if ( posix_getpwnam( $userName ) === false ) { 371 die( "skip user $userName does not exist" ); 372 } 373 } 374 375 /** 376 * Tester constructor. 377 * 378 * @param string|array $configTemplate 379 * @param string $code 380 * @param array $options 381 * @param string|null $fileName 382 * @param bool|null $debug 383 */ 384 public function __construct( 385 string|array $configTemplate, 386 string $code = '', 387 array $options = [], 388 ?string $fileName = null, 389 ?bool $debug = null, 390 string $clientTransport = 'stream' 391 ) { 392 $this->configTemplate = $configTemplate; 393 $this->code = $code; 394 $this->options = $options; 395 $this->fileName = $fileName ?: self::getCallerFileName(); 396 $this->debug = $debug !== null ? $debug : (bool)getenv('TEST_FPM_DEBUG'); 397 $this->logReader = new LogReader($this->debug); 398 $this->logTool = new LogTool($this->logReader, $this->debug); 399 $this->clientTransport = $clientTransport; 400 } 401 402 /** 403 * Creates new client transport. 404 * 405 * @return Transport 406 */ 407 private function createTransport() 408 { 409 return match ($this->clientTransport) { 410 'stream' => new StreamTransport(), 411 'socket' => new SocketTransport(), 412 }; 413 } 414 415 /** 416 * @param string $ini 417 */ 418 public function setUserIni(string $ini) 419 { 420 $iniFile = __DIR__ . '/.user.ini'; 421 $this->trace('Setting .user.ini file', $ini, isFile: true); 422 file_put_contents($iniFile, $ini); 423 } 424 425 /** 426 * Test configuration file. 427 * 428 * @return null|array 429 * @throws \Exception 430 */ 431 public function testConfig( 432 $silent = false, 433 array|string|null $expectedPattern = null, 434 $dumpConfig = true, 435 $printOutput = false 436 ): ?array { 437 $configFile = $this->createConfig(); 438 $configTestArg = $dumpConfig ? '-tt' : '-t'; 439 $cmd = self::findExecutable() . " -n $configTestArg -y $configFile 2>&1"; 440 $this->trace('Testing config using command', $cmd, true); 441 exec($cmd, $output, $code); 442 if ($printOutput) { 443 foreach ($output as $outputLine) { 444 echo $outputLine . "\n"; 445 } 446 } 447 $found = 0; 448 if ($expectedPattern !== null) { 449 $expectedPatterns = is_array($expectedPattern) ? $expectedPattern : [$expectedPattern]; 450 } 451 if ($code) { 452 $messages = []; 453 foreach ($output as $outputLine) { 454 $message = preg_replace("/\[.+?\]/", "", $outputLine, 1); 455 if ($expectedPattern !== null) { 456 for ($i = 0; $i < count($expectedPatterns); $i++) { 457 $pattern = $expectedPatterns[$i]; 458 if ($pattern !== null && preg_match($pattern, $message)) { 459 $found++; 460 $expectedPatterns[$i] = null; 461 } 462 } 463 } 464 $messages[] = $message; 465 if ( ! $silent) { 466 $this->error($message, null, false); 467 } 468 } 469 } else { 470 $messages = null; 471 } 472 473 if ($expectedPattern !== null && $found < count($expectedPatterns)) { 474 $missingPatterns = array_filter($expectedPatterns); 475 $errorMessage = sprintf( 476 "The expected config %s %s %s not been found", 477 count($missingPatterns) > 1 ? 'patterns' : 'pattern', 478 implode(', ', $missingPatterns), 479 count($missingPatterns) > 1 ? 'have' : 'has', 480 ); 481 $this->error($errorMessage); 482 } 483 484 return $messages; 485 } 486 487 /** 488 * Start PHP-FPM master process 489 * 490 * @param array $extraArgs Command extra arguments. 491 * @param bool $forceStderr Whether to output to stderr so error log is used. 492 * @param bool $daemonize Whether to start FPM daemonized 493 * @param array $extensions List of extension to add if shared build used. 494 * @param array $iniEntries List of ini entries to use. 495 * @param array|null $envVars List of env variable to execute FPM with or null to use the current ones. 496 * 497 * @return bool 498 * @throws \Exception 499 */ 500 public function start( 501 array $extraArgs = [], 502 bool $forceStderr = true, 503 bool $daemonize = false, 504 array $extensions = [], 505 array $iniEntries = [], 506 ?array $envVars = null, 507 ) { 508 $configFile = $this->createConfig(); 509 $desc = $this->outDesc ? [] : [1 => array('pipe', 'w'), 2 => array('redirect', 1)]; 510 511 $cmd = [self::findExecutable(), '-n', '-y', $configFile]; 512 513 if ($forceStderr) { 514 $cmd[] = '-O'; 515 } 516 517 $this->daemonized = $daemonize; 518 if ( ! $daemonize) { 519 $cmd[] = '-F'; 520 } 521 522 $extensionDir = getenv('TEST_FPM_EXTENSION_DIR'); 523 if ($extensionDir) { 524 $cmd[] = '-dextension_dir=' . $extensionDir; 525 foreach ($extensions as $extension) { 526 $cmd[] = '-dextension=' . $extension; 527 } 528 } 529 530 foreach ($iniEntries as $iniEntryName => $iniEntryValue) { 531 $cmd[] = '-d' . $iniEntryName . '=' . $iniEntryValue; 532 } 533 534 if (getenv('TEST_FPM_RUN_AS_ROOT')) { 535 $cmd[] = '--allow-to-run-as-root'; 536 } 537 $cmd = array_merge($cmd, $extraArgs); 538 $this->trace('Starting FPM using command:', $cmd, true); 539 540 $this->masterProcess = proc_open($cmd, $desc, $pipes, null, $envVars); 541 register_shutdown_function( 542 function ($masterProcess) use ($configFile) { 543 @unlink($configFile); 544 if (is_resource($masterProcess)) { 545 @proc_terminate($masterProcess); 546 while (proc_get_status($masterProcess)['running']) { 547 usleep(10000); 548 } 549 } 550 }, 551 $this->masterProcess 552 ); 553 if ( ! $this->outDesc !== false) { 554 $this->outDesc = $pipes[1]; 555 $this->logReader->setStreamSource('{{MASTER:OUT}}', $this->outDesc); 556 if ($daemonize) { 557 $this->switchLogSource('{{FILE:LOG}}'); 558 } 559 } 560 561 return true; 562 } 563 564 /** 565 * Run until needle is found in the log. 566 * 567 * @param string $pattern Search pattern to find. 568 * 569 * @return bool 570 * @throws \Exception 571 */ 572 public function runTill(string $pattern) 573 { 574 $this->start(); 575 $found = $this->logTool->expectPattern($pattern); 576 $this->close(true); 577 578 return $found; 579 } 580 581 /** 582 * Check if connection works. 583 * 584 * @param string $host 585 * @param string|null $successMessage 586 * @param string|null $errorMessage 587 * @param int $attempts 588 * @param int $delay 589 */ 590 public function checkConnection( 591 string $host = '127.0.0.1', 592 ?string $successMessage = null, 593 ?string $errorMessage = 'Connection failed', 594 int $attempts = 20, 595 int $delay = 50000 596 ) { 597 $i = 0; 598 do { 599 if ($i > 0 && $delay > 0) { 600 usleep($delay); 601 } 602 $fp = @fsockopen($host, $this->getPort()); 603 } while ((++$i < $attempts) && ! $fp); 604 605 if ($fp) { 606 $this->trace('Checking connection successful'); 607 $this->message($successMessage); 608 fclose($fp); 609 } else { 610 $this->message($errorMessage); 611 } 612 } 613 614 615 /** 616 * Execute request with parameters ordered for better checking. 617 * 618 * @param string $address 619 * @param string|null $successMessage 620 * @param string|null $errorMessage 621 * @param string $uri 622 * @param string $query 623 * @param array $headers 624 * 625 * @return Response 626 */ 627 public function checkRequest( 628 string $address, 629 ?string $successMessage = null, 630 ?string $errorMessage = null, 631 string $uri = '/ping', 632 string $query = '', 633 array $headers = [] 634 ): Response { 635 return $this->request($query, $headers, $uri, $address, $successMessage, $errorMessage); 636 } 637 638 /** 639 * Execute and check ping request. 640 * 641 * @param string $address 642 * @param string $pingPath 643 * @param string $pingResponse 644 */ 645 public function ping( 646 string $address = '{{ADDR}}', 647 string $pingResponse = 'pong', 648 string $pingPath = '/ping' 649 ) { 650 $response = $this->request('', [], $pingPath, $address); 651 $response->expectBody($pingResponse, 'text/plain'); 652 } 653 654 /** 655 * Execute and check status request(s). 656 * 657 * @param array $expectedFields 658 * @param string|null $address 659 * @param string $statusPath 660 * @param mixed $formats 661 * 662 * @throws \Exception 663 */ 664 public function status( 665 array $expectedFields, 666 ?string $address = null, 667 string $statusPath = '/status', 668 $formats = ['plain', 'html', 'xml', 'json', 'openmetrics'] 669 ) { 670 if ( ! is_array($formats)) { 671 $formats = [$formats]; 672 } 673 674 require_once "status.inc"; 675 $status = new Status($this); 676 foreach ($formats as $format) { 677 $query = $format === 'plain' ? '' : $format; 678 $response = $this->request($query, [], $statusPath, $address); 679 $status->checkStatus($response, $expectedFields, $format); 680 } 681 } 682 683 /** 684 * Get request params array. 685 * 686 * @param string $query 687 * @param array $headers 688 * @param string|null $uri 689 * @param string|null $scriptFilename 690 * @param string|null $stdin 691 * 692 * @return array 693 */ 694 private function getRequestParams( 695 string $query = '', 696 array $headers = [], 697 ?string $uri = null, 698 ?string $scriptFilename = null, 699 ?string $scriptName = null, 700 ?string $stdin = null, 701 ?string $method = null, 702 ): array { 703 if (is_null($scriptFilename)) { 704 $scriptFilename = $this->makeSourceFile(); 705 } 706 if (is_null($uri)) { 707 $uri = '/' . basename($scriptFilename); 708 } 709 if (is_null($scriptName)) { 710 $scriptName = $uri; 711 } 712 713 $params = array_merge( 714 [ 715 'GATEWAY_INTERFACE' => 'FastCGI/1.0', 716 'REQUEST_METHOD' => $method ?? (is_null($stdin) ? 'GET' : 'POST'), 717 'SCRIPT_FILENAME' => $scriptFilename === '' ? null : $scriptFilename, 718 'SCRIPT_NAME' => $scriptName, 719 'QUERY_STRING' => $query, 720 'REQUEST_URI' => $uri . ($query ? '?' . $query : ""), 721 'DOCUMENT_URI' => $uri, 722 'SERVER_SOFTWARE' => 'php/fcgiclient', 723 'REMOTE_ADDR' => '127.0.0.1', 724 'REMOTE_PORT' => '7777', 725 'SERVER_ADDR' => '127.0.0.1', 726 'SERVER_PORT' => '80', 727 'SERVER_NAME' => php_uname('n'), 728 'SERVER_PROTOCOL' => 'HTTP/1.1', 729 'DOCUMENT_ROOT' => __DIR__, 730 'CONTENT_TYPE' => '', 731 'CONTENT_LENGTH' => strlen($stdin ?? "") // Default to 0 732 ], 733 $headers 734 ); 735 736 return array_filter($params, function ($value) { 737 return ! is_null($value); 738 }); 739 } 740 741 /** 742 * Parse stdin and generate data for multipart config. 743 * 744 * @param array $stdin 745 * @param array $headers 746 * 747 * @return void 748 * @throws \Exception 749 */ 750 private function parseStdin(array $stdin, array &$headers) 751 { 752 $parts = $stdin['parts'] ?? null; 753 if (empty($parts)) { 754 throw new \Exception('The stdin array needs to contain parts'); 755 } 756 $boundary = $stdin['boundary'] ?? 'AaB03x'; 757 if ( ! isset($headers['CONTENT_TYPE'])) { 758 $headers['CONTENT_TYPE'] = 'multipart/form-data; boundary=' . $boundary; 759 } 760 $count = $parts['count'] ?? null; 761 if ( ! is_null($count)) { 762 $dispositionType = $parts['disposition'] ?? 'form-data'; 763 $dispositionParam = $parts['param'] ?? 'name'; 764 $namePrefix = $parts['prefix'] ?? 'f'; 765 $nameSuffix = $parts['suffix'] ?? ''; 766 $value = $parts['value'] ?? 'test'; 767 $parts = []; 768 for ($i = 0; $i < $count; $i++) { 769 $parts[] = [ 770 'disposition' => $dispositionType, 771 'param' => $dispositionParam, 772 'name' => "$namePrefix$i$nameSuffix", 773 'value' => $value 774 ]; 775 } 776 } 777 $out = ''; 778 $nl = "\r\n"; 779 foreach ($parts as $part) { 780 if (!is_array($part)) { 781 $part = ['name' => $part]; 782 } elseif ( ! isset($part['name'])) { 783 throw new \Exception('Each part has to have a name'); 784 } 785 $name = $part['name']; 786 $dispositionType = $part['disposition'] ?? 'form-data'; 787 $dispositionParam = $part['param'] ?? 'name'; 788 $value = $part['value'] ?? 'test'; 789 $partHeaders = $part['headers'] ?? []; 790 791 $out .= "--$boundary$nl"; 792 $out .= "Content-disposition: $dispositionType; $dispositionParam=\"$name\"$nl"; 793 foreach ($partHeaders as $headerName => $headerValue) { 794 $out .= "$headerName: $headerValue$nl"; 795 } 796 $out .= $nl; 797 $out .= "$value$nl"; 798 } 799 $out .= "--$boundary--$nl"; 800 801 return $out; 802 } 803 804 /** 805 * Execute request. 806 * 807 * @param string $query 808 * @param array $headers 809 * @param string|null $uri 810 * @param string|null $address 811 * @param string|null $successMessage 812 * @param string|null $errorMessage 813 * @param bool $connKeepAlive 814 * @param bool $socketKeepAlive 815 * @param string|null $scriptFilename = null 816 * @param string|null $scriptName = null 817 * @param string|array|null $stdin = null 818 * @param bool $expectError 819 * @param int $readLimit 820 * @param int $writeDelay 821 * 822 * @return Response 823 * @throws \Exception 824 */ 825 public function request( 826 string $query = '', 827 array $headers = [], 828 ?string $uri = null, 829 ?string $address = null, 830 ?string $successMessage = null, 831 ?string $errorMessage = null, 832 bool $connKeepAlive = false, 833 bool $socketKeepAlive = false, 834 ?string $scriptFilename = null, 835 ?string $scriptName = null, 836 string|array|null $stdin = null, 837 bool $expectError = false, 838 int $readLimit = -1, 839 int $writeDelay = 0, 840 ?string $method = null, 841 ): Response { 842 if ($this->hasError()) { 843 return $this->createResponse(expectInvalid: true); 844 } 845 846 if (is_array($stdin)) { 847 $stdin = $this->parseStdin($stdin, $headers); 848 } 849 850 $params = $this->getRequestParams($query, $headers, $uri, $scriptFilename, $scriptName, $stdin, $method); 851 $this->trace('Request params', $params); 852 853 try { 854 $this->response = $this->createResponse( 855 $this->getClient($address, $connKeepAlive, $socketKeepAlive) 856 ->request_data($params, $stdin, $readLimit, $writeDelay) 857 ); 858 if ($expectError) { 859 $this->error('Expected request error but the request was successful'); 860 } else { 861 $this->message($successMessage); 862 } 863 } catch (\Exception $exception) { 864 if ($expectError) { 865 $this->message($successMessage); 866 } elseif ($errorMessage === null) { 867 $this->error("Request failed", $exception); 868 } else { 869 $this->message($errorMessage); 870 } 871 $this->response = $this->createResponse(); 872 } 873 if ($this->debug) { 874 $this->response->debugOutput(); 875 } 876 877 return $this->response; 878 } 879 880 /** 881 * Execute multiple requests in parallel. 882 * 883 * @param int|array $requests 884 * @param string|null $address 885 * @param string|null $successMessage 886 * @param string|null $errorMessage 887 * @param bool $socketKeepAlive 888 * @param bool $connKeepAlive 889 * @param int $readTimeout 890 * @param int $writeDelay 891 * 892 * @return Response[] 893 * @throws \Exception 894 */ 895 public function multiRequest( 896 int|array $requests, 897 ?string $address = null, 898 ?string $successMessage = null, 899 ?string $errorMessage = null, 900 bool $connKeepAlive = false, 901 bool $socketKeepAlive = false, 902 int $readTimeout = 0, 903 int $writeDelay = 0, 904 ) { 905 if (is_numeric($requests)) { 906 $requests = array_fill(0, $requests, []); 907 } 908 909 if ($this->hasError()) { 910 return array_map(fn($request) => $this->createResponse(expectInvalid: true), $requests); 911 } 912 913 try { 914 $connections = array_map( 915 function ($requestData) use ($address, $connKeepAlive, $socketKeepAlive, $writeDelay) { 916 $client = $this->getClient($address, $connKeepAlive, $socketKeepAlive); 917 $params = $this->getRequestParams( 918 $requestData['query'] ?? '', 919 $requestData['headers'] ?? [], 920 $requestData['uri'] ?? null 921 ); 922 923 if (isset($requestData['delay'])) { 924 usleep($requestData['delay']); 925 } 926 927 return [ 928 'client' => $client, 929 'requestId' => $client->async_request($params, false, $writeDelay), 930 ]; 931 }, 932 $requests 933 ); 934 935 $responses = array_map(function ($conn) use ($readTimeout) { 936 $response = $this->createResponse( 937 $conn['client']->wait_for_response_data($conn['requestId'], $readTimeout) 938 ); 939 if ($this->debug) { 940 $response->debugOutput(); 941 } 942 943 return $response; 944 }, $connections); 945 $this->message($successMessage); 946 947 return $responses; 948 } catch (\Exception $exception) { 949 if ($errorMessage === null) { 950 $this->error("Request failed", $exception); 951 } else { 952 $this->message($errorMessage); 953 } 954 955 return array_map(fn($request) => $this->createResponse(expectInvalid: true), $requests); 956 } 957 } 958 959 /** 960 * Execute request for getting FastCGI values. 961 * 962 * @param string|null $address 963 * @param bool $connKeepAlive 964 * @param bool $socketKeepAlive 965 * 966 * @return ValuesResponse 967 * @throws \Exception 968 */ 969 public function requestValues( 970 ?string $address = null, 971 bool $connKeepAlive = false, 972 bool $socketKeepAlive = false 973 ): ValuesResponse { 974 if ($this->hasError()) { 975 return $this->createValueResponse(); 976 } 977 978 try { 979 $valueResponse = $this->createValueResponse( 980 $this->getClient($address, $connKeepAlive)->getValues(['FCGI_MPXS_CONNS']) 981 ); 982 if ($this->debug) { 983 $this->response->debugOutput(); 984 } 985 } catch (\Exception $exception) { 986 $this->error("Request for getting values failed", $exception); 987 $valueResponse = $this->createValueResponse(); 988 } 989 990 return $valueResponse; 991 } 992 993 /** 994 * Get client. 995 * 996 * @param string|null $address 997 * @param bool $connKeepAlive 998 * @param bool $socketKeepAlive 999 * 1000 * @return Client 1001 */ 1002 private function getClient( 1003 ?string $address = null, 1004 bool $connKeepAlive = false, 1005 bool $socketKeepAlive = false 1006 ): Client { 1007 $address = $address ? $this->processTemplate($address) : $this->getAddr(); 1008 if ($address[0] === '/') { // uds 1009 $host = 'unix://' . $address; 1010 $port = -1; 1011 } elseif ($address[0] === '[') { // ipv6 1012 $addressParts = explode(']:', $address); 1013 $host = $addressParts[0]; 1014 if (isset($addressParts[1])) { 1015 $host .= ']'; 1016 $port = $addressParts[1]; 1017 } else { 1018 $port = $this->getPort(); 1019 } 1020 } else { // ipv4 1021 $addressParts = explode(':', $address); 1022 $host = $addressParts[0]; 1023 $port = $addressParts[1] ?? $this->getPort(); 1024 } 1025 1026 if ($socketKeepAlive) { 1027 $connKeepAlive = true; 1028 } 1029 if ( ! $connKeepAlive) { 1030 return new Client($host, $port, $this->createTransport()); 1031 } 1032 1033 if ( ! isset($this->clients[$host][$port])) { 1034 $client = new Client($host, $port, $this->createTransport()); 1035 $client->setKeepAlive($connKeepAlive, $socketKeepAlive); 1036 $this->clients[$host][$port] = $client; 1037 } 1038 1039 return $this->clients[$host][$port]; 1040 } 1041 1042 /** 1043 * @return string 1044 */ 1045 public function getUser() 1046 { 1047 return get_current_user(); 1048 } 1049 1050 /** 1051 * @return string 1052 */ 1053 public function getGroup() 1054 { 1055 return get_current_group(); 1056 } 1057 1058 /** 1059 * @return int 1060 */ 1061 public function getUid() 1062 { 1063 return getmyuid(); 1064 } 1065 1066 /** 1067 * @return int 1068 */ 1069 public function getGid() 1070 { 1071 return getmygid(); 1072 } 1073 1074 /** 1075 * Reload FPM by sending USR2 signal and optionally change config before that. 1076 * 1077 * @param string|array $configTemplate 1078 * 1079 * @return string 1080 * @throws \Exception 1081 */ 1082 public function reload($configTemplate = null) 1083 { 1084 if ( ! is_null($configTemplate)) { 1085 self::cleanConfigFiles(); 1086 $this->configTemplate = $configTemplate; 1087 $this->createConfig(); 1088 } 1089 1090 return $this->signal('USR2'); 1091 } 1092 1093 /** 1094 * Reload FPM logs by sending USR1 signal. 1095 * 1096 * @return string 1097 * @throws \Exception 1098 */ 1099 public function reloadLogs(): string 1100 { 1101 return $this->signal('USR1'); 1102 } 1103 1104 /** 1105 * Send signal to the supplied PID or the server PID. 1106 * 1107 * @param string $signal 1108 * @param int|null $pid 1109 * 1110 * @return string 1111 */ 1112 public function signal($signal, ?int $pid = null) 1113 { 1114 if (is_null($pid)) { 1115 $pid = $this->getPid(); 1116 } 1117 $cmd = "kill -$signal $pid"; 1118 $this->trace('Sending signal using command', $cmd, true); 1119 1120 return exec("kill -$signal $pid"); 1121 } 1122 1123 /** 1124 * Terminate master process 1125 */ 1126 public function terminate() 1127 { 1128 if ($this->daemonized) { 1129 $this->signal('TERM'); 1130 } else { 1131 proc_terminate($this->masterProcess); 1132 } 1133 } 1134 1135 /** 1136 * Close all open descriptors and process resources 1137 * 1138 * @param bool $terminate 1139 */ 1140 public function close($terminate = false) 1141 { 1142 if ($terminate) { 1143 $this->terminate(); 1144 } 1145 proc_close($this->masterProcess); 1146 } 1147 1148 /** 1149 * Create a config file. 1150 * 1151 * @param string $extension 1152 * 1153 * @return string 1154 * @throws \Exception 1155 */ 1156 private function createConfig($extension = 'ini') 1157 { 1158 if (is_array($this->configTemplate)) { 1159 $configTemplates = $this->configTemplate; 1160 if ( ! isset($configTemplates['main'])) { 1161 throw new \Exception('The config template array has to have main config'); 1162 } 1163 $mainTemplate = $configTemplates['main']; 1164 if ( ! is_dir(self::CONF_DIR)) { 1165 mkdir(self::CONF_DIR); 1166 } 1167 foreach ($this->createPoolConfigs($configTemplates) as $name => $poolConfig) { 1168 $this->makeFile( 1169 'conf', 1170 $this->processTemplate($poolConfig), 1171 self::CONF_DIR, 1172 $name 1173 ); 1174 } 1175 } else { 1176 $mainTemplate = $this->configTemplate; 1177 } 1178 1179 return $this->makeFile($extension, $this->processTemplate($mainTemplate)); 1180 } 1181 1182 /** 1183 * Create pool config templates. 1184 * 1185 * @param array $configTemplates 1186 * 1187 * @return array 1188 * @throws \Exception 1189 */ 1190 private function createPoolConfigs(array $configTemplates) 1191 { 1192 if ( ! isset($configTemplates['poolTemplate'])) { 1193 unset($configTemplates['main']); 1194 1195 return $configTemplates; 1196 } 1197 $poolTemplate = $configTemplates['poolTemplate']; 1198 $configs = []; 1199 if (isset($configTemplates['count'])) { 1200 $start = $configTemplates['start'] ?? 1; 1201 for ($i = $start; $i < $start + $configTemplates['count']; $i++) { 1202 $configs[$i] = str_replace('%index%', $i, $poolTemplate); 1203 } 1204 } elseif (isset($configTemplates['names'])) { 1205 foreach ($configTemplates['names'] as $name) { 1206 $configs[$name] = str_replace('%name%', $name, $poolTemplate); 1207 } 1208 } else { 1209 throw new \Exception('The config template requires count or names if poolTemplate set'); 1210 } 1211 1212 return $configs; 1213 } 1214 1215 /** 1216 * Process template string. 1217 * 1218 * @param string $template 1219 * 1220 * @return string 1221 */ 1222 private function processTemplate(string $template) 1223 { 1224 $vars = [ 1225 'FILE:LOG:ACC' => ['getAbsoluteFile', self::FILE_EXT_LOG_ACC], 1226 'FILE:LOG:ERR' => ['getAbsoluteFile', self::FILE_EXT_LOG_ERR], 1227 'FILE:LOG:SLOW' => ['getAbsoluteFile', self::FILE_EXT_LOG_SLOW], 1228 'FILE:PID' => ['getAbsoluteFile', self::FILE_EXT_PID], 1229 'RFILE:LOG:ACC' => ['getRelativeFile', self::FILE_EXT_LOG_ACC], 1230 'RFILE:LOG:ERR' => ['getRelativeFile', self::FILE_EXT_LOG_ERR], 1231 'RFILE:LOG:SLOW' => ['getRelativeFile', self::FILE_EXT_LOG_SLOW], 1232 'RFILE:PID' => ['getRelativeFile', self::FILE_EXT_PID], 1233 'ADDR:IPv4' => ['getAddr', 'ipv4'], 1234 'ADDR:IPv4:ANY' => ['getAddr', 'ipv4-any'], 1235 'ADDR:IPv6' => ['getAddr', 'ipv6'], 1236 'ADDR:IPv6:ANY' => ['getAddr', 'ipv6-any'], 1237 'ADDR:UDS' => ['getAddr', 'uds'], 1238 'PORT' => ['getPort', 'ip'], 1239 'INCLUDE:CONF' => self::CONF_DIR . '/*.conf', 1240 'USER' => ['getUser'], 1241 'GROUP' => ['getGroup'], 1242 'UID' => ['getUid'], 1243 'GID' => ['getGid'], 1244 'MASTER:OUT' => 'pipe:1', 1245 'STDERR' => '/dev/stderr', 1246 'STDOUT' => '/dev/stdout', 1247 ]; 1248 $aliases = [ 1249 'ADDR' => 'ADDR:IPv4', 1250 'FILE:LOG' => 'FILE:LOG:ERR', 1251 ]; 1252 foreach ($aliases as $aliasName => $aliasValue) { 1253 $vars[$aliasName] = $vars[$aliasValue]; 1254 } 1255 1256 return preg_replace_callback( 1257 '/{{([a-zA-Z0-9:]+)(\[\w+\])?}}/', 1258 function ($matches) use ($vars) { 1259 $varName = $matches[1]; 1260 if ( ! isset($vars[$varName])) { 1261 $this->error("Invalid config variable $varName"); 1262 1263 return 'INVALID'; 1264 } 1265 $pool = $matches[2] ?? 'default'; 1266 $varValue = $vars[$varName]; 1267 if (is_string($varValue)) { 1268 return $varValue; 1269 } 1270 $functionName = array_shift($varValue); 1271 $varValue[] = $pool; 1272 1273 return call_user_func_array([$this, $functionName], $varValue); 1274 }, 1275 $template 1276 ); 1277 } 1278 1279 /** 1280 * @param string $type 1281 * @param string $pool 1282 * 1283 * @return string 1284 */ 1285 public function getAddr(string $type = 'ipv4', $pool = 'default') 1286 { 1287 $port = $this->getPort($type, $pool, true); 1288 if ($type === 'uds') { 1289 $address = $this->getFile($port . '.sock'); 1290 1291 // Socket max path length is 108 on Linux and 104 on BSD, 1292 // so we use the latter 1293 if (strlen($address) <= 104) { 1294 return $address; 1295 } 1296 1297 $addressPart = hash('crc32', dirname($address)) . '-' . basename($address); 1298 1299 // is longer on Mac, than on Linux 1300 $tmpDirAddress = sys_get_temp_dir() . '/' . $addressPart; 1301 ; 1302 1303 if (strlen($tmpDirAddress) <= 104) { 1304 return $tmpDirAddress; 1305 } 1306 1307 $srcRootAddress = dirname(__DIR__, 3) . '/' . $addressPart; 1308 1309 return $srcRootAddress; 1310 } 1311 1312 return $this->getHost($type) . ':' . $port; 1313 } 1314 1315 /** 1316 * @param string $type 1317 * @param string $pool 1318 * @param bool $useAsId 1319 * 1320 * @return int 1321 */ 1322 public function getPort(string $type = 'ip', $pool = 'default', $useAsId = false) 1323 { 1324 if ($type === 'uds' && ! $useAsId) { 1325 return -1; 1326 } 1327 1328 if (isset($this->ports['values'][$pool])) { 1329 return $this->ports['values'][$pool]; 1330 } 1331 $port = ($this->ports['last'] ?? 9000 + PHP_INT_SIZE - 1) + 1; 1332 $this->ports['values'][$pool] = $this->ports['last'] = $port; 1333 1334 return $port; 1335 } 1336 1337 /** 1338 * @param string $type 1339 * 1340 * @return string 1341 */ 1342 public function getHost(string $type = 'ipv4') 1343 { 1344 switch ($type) { 1345 case 'ipv6-any': 1346 return '[::]'; 1347 case 'ipv6': 1348 return '[::1]'; 1349 case 'ipv4-any': 1350 return '0.0.0.0'; 1351 default: 1352 return '127.0.0.1'; 1353 } 1354 } 1355 1356 /** 1357 * Get listen address. 1358 * 1359 * @param string|null $template 1360 * 1361 * @return string 1362 */ 1363 public function getListen($template = null) 1364 { 1365 return $template ? $this->processTemplate($template) : $this->getAddr(); 1366 } 1367 1368 /** 1369 * Get PID. 1370 * 1371 * @return int 1372 */ 1373 public function getPid() 1374 { 1375 $pidFile = $this->getFile('pid'); 1376 if ( ! is_file($pidFile)) { 1377 return (int)$this->error("PID file has not been created"); 1378 } 1379 $pidContent = file_get_contents($pidFile); 1380 if ( ! is_numeric($pidContent)) { 1381 return (int)$this->error("PID content '$pidContent' is not integer"); 1382 } 1383 $this->trace('PID found', $pidContent); 1384 1385 return (int)$pidContent; 1386 } 1387 1388 1389 /** 1390 * Get file path for resource file. 1391 * 1392 * @param string $extension 1393 * @param string|null $dir 1394 * @param string|null $name 1395 * 1396 * @return string 1397 */ 1398 private function getFile(string $extension, ?string $dir = null, ?string $name = null): string 1399 { 1400 $fileName = (is_null($name) ? $this->fileName : $name . '.') . $extension; 1401 1402 return is_null($dir) ? $fileName : $dir . '/' . $fileName; 1403 } 1404 1405 /** 1406 * Get absolute file path for the resource file used by templates. 1407 * 1408 * @param string $extension 1409 * 1410 * @return string 1411 */ 1412 private function getAbsoluteFile(string $extension): string 1413 { 1414 return $this->getFile($extension); 1415 } 1416 1417 /** 1418 * Get relative file name for resource file used by templates. 1419 * 1420 * @param string $extension 1421 * 1422 * @return string 1423 */ 1424 private function getRelativeFile(string $extension): string 1425 { 1426 $fileName = rtrim(basename($this->fileName), '.'); 1427 1428 return $this->getFile($extension, null, $fileName); 1429 } 1430 1431 /** 1432 * Get prefixed file. 1433 * 1434 * @param string $extension 1435 * @param string|null $prefix 1436 * 1437 * @return string 1438 */ 1439 public function getPrefixedFile(string $extension, ?string $prefix = null): string 1440 { 1441 $fileName = rtrim($this->fileName, '.'); 1442 if ( ! is_null($prefix)) { 1443 $fileName = $prefix . '/' . basename($fileName); 1444 } 1445 1446 return $this->getFile($extension, null, $fileName); 1447 } 1448 1449 /** 1450 * Create a resource file. 1451 * 1452 * @param string $extension 1453 * @param string $content 1454 * @param string|null $dir 1455 * @param string|null $name 1456 * 1457 * @return string 1458 */ 1459 private function makeFile( 1460 string $extension, 1461 string $content = '', 1462 ?string $dir = null, 1463 ?string $name = null, 1464 bool $overwrite = true 1465 ): string { 1466 $filePath = $this->getFile($extension, $dir, $name); 1467 if ( ! $overwrite && is_file($filePath)) { 1468 return $filePath; 1469 } 1470 file_put_contents($filePath, $content); 1471 1472 $this->trace('Created file: ' . $filePath, $content, isFile: true); 1473 1474 return $filePath; 1475 } 1476 1477 /** 1478 * Create a source code file. 1479 * 1480 * @return string 1481 */ 1482 public function makeSourceFile(): string 1483 { 1484 return $this->makeFile('src.php', $this->code, overwrite: false); 1485 } 1486 1487 /** 1488 * Create a source file and script name. 1489 * 1490 * @return string[] 1491 */ 1492 public function createSourceFileAndScriptName(): array 1493 { 1494 $sourceFile = $this->makeFile('src.php', $this->code, overwrite: false); 1495 1496 return [$sourceFile, '/' . basename($sourceFile)]; 1497 } 1498 1499 /** 1500 * Create a new response. 1501 * 1502 * @param mixed $data 1503 * @param bool $expectInvalid 1504 * @return Response 1505 */ 1506 private function createResponse($data = null, bool $expectInvalid = false): Response 1507 { 1508 return new Response($this, $data, $expectInvalid); 1509 } 1510 1511 /** 1512 * Create a new values response. 1513 * 1514 * @param mixed $values 1515 * @return ValuesResponse 1516 * @throws \Exception 1517 */ 1518 private function createValueResponse($values = null): ValuesResponse 1519 { 1520 return new ValuesResponse($this, $values); 1521 } 1522 1523 /** 1524 * @param string|null $msg 1525 */ 1526 private function message($msg) 1527 { 1528 if ($msg !== null) { 1529 echo "$msg\n"; 1530 } 1531 } 1532 1533 /** 1534 * Print log reader logs. 1535 * 1536 * @return void 1537 */ 1538 public function printLogs(): void 1539 { 1540 $this->logReader->printLogs(); 1541 } 1542 1543 /** 1544 * Display error. 1545 * 1546 * @param string $msg Error message. 1547 * @param \Exception|null $exception If there is an exception, log its message 1548 * @param bool $prefix Whether to prefix the error message 1549 * 1550 * @return false 1551 */ 1552 private function error(string $msg, ?\Exception $exception = null, bool $prefix = true): bool 1553 { 1554 $this->error = $prefix ? 'ERROR: ' . $msg : ltrim($msg); 1555 if ($exception) { 1556 $this->error .= '; EXCEPTION: ' . $exception->getMessage(); 1557 } 1558 $this->error .= "\n"; 1559 1560 echo $this->error; 1561 $this->printLogs(); 1562 1563 return false; 1564 } 1565 1566 /** 1567 * Check whether any error was set. 1568 * 1569 * @return bool 1570 */ 1571 private function hasError() 1572 { 1573 return ! is_null($this->error) || ! is_null($this->logTool->getError()); 1574 } 1575 1576 /** 1577 * Expect file with a supplied extension to exist. 1578 * 1579 * @param string $extension 1580 * @param string $prefix 1581 * 1582 * @return bool 1583 */ 1584 public function expectFile(string $extension, $prefix = null) 1585 { 1586 $filePath = $this->getPrefixedFile($extension, $prefix); 1587 if ( ! file_exists($filePath)) { 1588 return $this->error("The file $filePath does not exist"); 1589 } 1590 $this->trace('File path exists as expected', $filePath); 1591 1592 return true; 1593 } 1594 1595 /** 1596 * Expect file with a supplied extension to not exist. 1597 * 1598 * @param string $extension 1599 * @param string $prefix 1600 * 1601 * @return bool 1602 */ 1603 public function expectNoFile(string $extension, $prefix = null) 1604 { 1605 $filePath = $this->getPrefixedFile($extension, $prefix); 1606 if (file_exists($filePath)) { 1607 return $this->error("The file $filePath exists"); 1608 } 1609 $this->trace('File path does not exist as expected', $filePath); 1610 1611 return true; 1612 } 1613 1614 /** 1615 * Expect message to be written to FastCGI error stream. 1616 * 1617 * @param string $message 1618 * @param int $limit 1619 * @param int $repeat 1620 */ 1621 public function expectFastCGIErrorMessage( 1622 string $message, 1623 int $limit = 1024, 1624 int $repeat = 0 1625 ) { 1626 $this->logTool->setExpectedMessage($message, $limit, $repeat); 1627 $this->logTool->checkTruncatedMessage($this->response->getErrorData()); 1628 } 1629 1630 /** 1631 * Expect log to be empty. 1632 * 1633 * @throws \Exception 1634 */ 1635 public function expectLogEmpty() 1636 { 1637 try { 1638 $line = $this->logReader->getLine(1, 0, true); 1639 if ($line === '') { 1640 $line = $this->logReader->getLine(1, 0, true); 1641 } 1642 if ($line !== null) { 1643 $this->error('Log is not closed and returned line: ' . $line); 1644 } 1645 } catch (LogTimoutException $exception) { 1646 $this->error('Log is not closed and timed out', $exception); 1647 } 1648 } 1649 1650 /** 1651 * Expect reloading lines to be logged. 1652 * 1653 * @param int $socketCount 1654 * @param bool $expectInitialProgressMessage 1655 * @param bool $expectReloadingMessage 1656 * 1657 * @throws \Exception 1658 */ 1659 public function expectLogReloadingNotices( 1660 int $socketCount = 1, 1661 bool $expectInitialProgressMessage = true, 1662 bool $expectReloadingMessage = true 1663 ) { 1664 $this->logTool->expectReloadingLines( 1665 $socketCount, 1666 $expectInitialProgressMessage, 1667 $expectReloadingMessage 1668 ); 1669 } 1670 1671 /** 1672 * Expect reloading lines to be logged. 1673 * 1674 * @throws \Exception 1675 */ 1676 public function expectLogReloadingLogsNotices() 1677 { 1678 $this->logTool->expectReloadingLogsLines(); 1679 } 1680 1681 /** 1682 * Expect starting lines to be logged. 1683 * @throws \Exception 1684 */ 1685 public function expectLogStartNotices() 1686 { 1687 $this->logTool->expectStartingLines(); 1688 } 1689 1690 /** 1691 * Expect terminating lines to be logged. 1692 * @throws \Exception 1693 */ 1694 public function expectLogTerminatingNotices() 1695 { 1696 $this->logTool->expectTerminatorLines(); 1697 } 1698 1699 /** 1700 * Expect log pattern in logs. 1701 * 1702 * @param string $pattern Log pattern 1703 * @param bool $checkAllLogs Whether to also check past logs. 1704 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1705 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1706 * 1707 * @throws \Exception 1708 */ 1709 public function expectLogPattern( 1710 string $pattern, 1711 bool $checkAllLogs = false, 1712 ?int $timeoutSeconds = null, 1713 ?int $timeoutMicroseconds = null, 1714 ) { 1715 $this->logTool->expectPattern( 1716 $pattern, 1717 false, 1718 $checkAllLogs, 1719 $timeoutSeconds, 1720 $timeoutMicroseconds 1721 ); 1722 } 1723 1724 /** 1725 * Expect no such log pattern in logs. 1726 * 1727 * @param string $pattern Log pattern 1728 * @param bool $checkAllLogs Whether to also check past logs. 1729 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1730 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1731 * 1732 * @throws \Exception 1733 */ 1734 public function expectNoLogPattern( 1735 string $pattern, 1736 bool $checkAllLogs = true, 1737 ?int $timeoutSeconds = null, 1738 ?int $timeoutMicroseconds = null, 1739 ) { 1740 if (is_null($timeoutSeconds) && is_null($timeoutMicroseconds)) { 1741 $timeoutMicroseconds = 10; 1742 } 1743 $this->logTool->expectPattern( 1744 $pattern, 1745 true, 1746 $checkAllLogs, 1747 $timeoutSeconds, 1748 $timeoutMicroseconds 1749 ); 1750 } 1751 1752 /** 1753 * Expect log message that can span multiple lines. 1754 * 1755 * @param string $message 1756 * @param int $limit 1757 * @param int $repeat 1758 * @param bool $decorated 1759 * @param bool $wrapped 1760 * 1761 * @throws \Exception 1762 */ 1763 public function expectLogMessage( 1764 string $message, 1765 int $limit = 1024, 1766 int $repeat = 0, 1767 bool $decorated = true, 1768 bool $wrapped = true 1769 ) { 1770 $this->logTool->setExpectedMessage($message, $limit, $repeat); 1771 if ($wrapped) { 1772 $this->logTool->checkWrappedMessage(true, $decorated); 1773 } else { 1774 $this->logTool->checkTruncatedMessage(); 1775 } 1776 } 1777 1778 /** 1779 * Expect a single log line. 1780 * 1781 * @param string $message The expected message. 1782 * @param bool $isStdErr Whether it is logged to stderr. 1783 * @param bool $decorated Whether the log lines are decorated. 1784 * 1785 * @return bool 1786 * @throws \Exception 1787 */ 1788 public function expectLogLine( 1789 string $message, 1790 bool $isStdErr = true, 1791 bool $decorated = true 1792 ): bool { 1793 $messageLen = strlen($message); 1794 $limit = $messageLen > 1024 ? $messageLen + 16 : 1024; 1795 $this->logTool->setExpectedMessage($message, $limit); 1796 1797 return $this->logTool->checkWrappedMessage(false, $decorated, $isStdErr); 1798 } 1799 1800 /** 1801 * Expect log entry. 1802 * 1803 * @param string $type The log type. 1804 * @param string $message The expected message. 1805 * @param string|null $pool The pool for pool prefixed log entry. 1806 * @param int $count The number of items. 1807 * @param bool $checkAllLogs Whether to also check past logs. 1808 * @param bool $invert Whether the log entry is not expected rather than expected. 1809 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1810 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1811 * @param string $ignoreErrorFor Ignore error for supplied string in the message. 1812 * 1813 * @return bool 1814 * @throws \Exception 1815 */ 1816 private function expectLogEntry( 1817 string $type, 1818 string $message, 1819 ?string $pool = null, 1820 int $count = 1, 1821 bool $checkAllLogs = false, 1822 bool $invert = false, 1823 ?int $timeoutSeconds = null, 1824 ?int $timeoutMicroseconds = null, 1825 string $ignoreErrorFor = LogTool::DEBUG 1826 ): bool { 1827 for ($i = 0; $i < $count; $i++) { 1828 $result = $this->logTool->expectEntry( 1829 $type, 1830 $message, 1831 $pool, 1832 $ignoreErrorFor, 1833 $checkAllLogs, 1834 $invert, 1835 $timeoutSeconds, 1836 $timeoutMicroseconds, 1837 ); 1838 1839 if ( ! $result) { 1840 return false; 1841 } 1842 } 1843 1844 return true; 1845 } 1846 1847 /** 1848 * Expect a log debug message. 1849 * 1850 * @param string $message The expected message. 1851 * @param string|null $pool The pool for pool prefixed log entry. 1852 * @param int $count The number of items. 1853 * @param bool $checkAllLogs Whether to also check past logs. 1854 * @param bool $invert Whether the log entry is not expected rather than expected. 1855 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1856 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1857 * 1858 * @return bool 1859 * @throws \Exception 1860 */ 1861 public function expectLogDebug( 1862 string $message, 1863 ?string $pool = null, 1864 int $count = 1, 1865 bool $checkAllLogs = false, 1866 bool $invert = false, 1867 ?int $timeoutSeconds = null, 1868 ?int $timeoutMicroseconds = null 1869 ): bool { 1870 return $this->expectLogEntry( 1871 LogTool::DEBUG, 1872 $message, 1873 $pool, 1874 $count, 1875 $checkAllLogs, 1876 $invert, 1877 $timeoutSeconds, 1878 $timeoutMicroseconds, 1879 LogTool::ERROR 1880 ); 1881 } 1882 1883 /** 1884 * Expect a log notice. 1885 * 1886 * @param string $message The expected message. 1887 * @param string|null $pool The pool for pool prefixed log entry. 1888 * @param int $count The number of items. 1889 * @param bool $checkAllLogs Whether to also check past logs. 1890 * @param bool $invert Whether the log entry is not expected rather than expected. 1891 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1892 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1893 * 1894 * @return bool 1895 * @throws \Exception 1896 */ 1897 public function expectLogNotice( 1898 string $message, 1899 ?string $pool = null, 1900 int $count = 1, 1901 bool $checkAllLogs = false, 1902 bool $invert = false, 1903 ?int $timeoutSeconds = null, 1904 ?int $timeoutMicroseconds = null 1905 ): bool { 1906 return $this->expectLogEntry( 1907 LogTool::NOTICE, 1908 $message, 1909 $pool, 1910 $count, 1911 $checkAllLogs, 1912 $invert, 1913 $timeoutSeconds, 1914 $timeoutMicroseconds 1915 ); 1916 } 1917 1918 /** 1919 * Expect a log warning. 1920 * 1921 * @param string $message The expected message. 1922 * @param string|null $pool The pool for pool prefixed log entry. 1923 * @param int $count The number of items. 1924 * @param bool $checkAllLogs Whether to also check past logs. 1925 * @param bool $invert Whether the log entry is not expected rather than expected. 1926 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1927 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1928 * 1929 * @return bool 1930 * @throws \Exception 1931 */ 1932 public function expectLogWarning( 1933 string $message, 1934 ?string $pool = null, 1935 int $count = 1, 1936 bool $checkAllLogs = false, 1937 bool $invert = false, 1938 ?int $timeoutSeconds = null, 1939 ?int $timeoutMicroseconds = null 1940 ): bool { 1941 return $this->expectLogEntry( 1942 LogTool::WARNING, 1943 $message, 1944 $pool, 1945 $count, 1946 $checkAllLogs, 1947 $invert, 1948 $timeoutSeconds, 1949 $timeoutMicroseconds 1950 ); 1951 } 1952 1953 /** 1954 * Expect a log error. 1955 * 1956 * @param string $message The expected message. 1957 * @param string|null $pool The pool for pool prefixed log entry. 1958 * @param int $count The number of items. 1959 * @param bool $checkAllLogs Whether to also check past logs. 1960 * @param bool $invert Whether the log entry is not expected rather than expected. 1961 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1962 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1963 * 1964 * @return bool 1965 * @throws \Exception 1966 */ 1967 public function expectLogError( 1968 string $message, 1969 ?string $pool = null, 1970 int $count = 1, 1971 bool $checkAllLogs = false, 1972 bool $invert = false, 1973 ?int $timeoutSeconds = null, 1974 ?int $timeoutMicroseconds = null 1975 ): bool { 1976 return $this->expectLogEntry( 1977 LogTool::ERROR, 1978 $message, 1979 $pool, 1980 $count, 1981 $checkAllLogs, 1982 $invert, 1983 $timeoutSeconds, 1984 $timeoutMicroseconds 1985 ); 1986 } 1987 1988 /** 1989 * Expect a log alert. 1990 * 1991 * @param string $message The expected message. 1992 * @param string|null $pool The pool for pool prefixed log entry. 1993 * @param int $count The number of items. 1994 * @param bool $checkAllLogs Whether to also check past logs. 1995 * @param bool $invert Whether the log entry is not expected rather than expected. 1996 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1997 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1998 * 1999 * @return bool 2000 * @throws \Exception 2001 */ 2002 public function expectLogAlert( 2003 string $message, 2004 ?string $pool = null, 2005 int $count = 1, 2006 bool $checkAllLogs = false, 2007 bool $invert = false, 2008 ?int $timeoutSeconds = null, 2009 ?int $timeoutMicroseconds = null 2010 ): bool { 2011 return $this->expectLogEntry( 2012 LogTool::ALERT, 2013 $message, 2014 $pool, 2015 $count, 2016 $checkAllLogs, 2017 $invert, 2018 $timeoutSeconds, 2019 $timeoutMicroseconds 2020 ); 2021 } 2022 2023 /** 2024 * Expect no log lines to be logged. 2025 * 2026 * @return bool 2027 * @throws \Exception 2028 */ 2029 public function expectNoLogMessages(): bool 2030 { 2031 $logLine = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000); 2032 if ($logLine === "") { 2033 $logLine = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000); 2034 } 2035 if ($logLine !== null) { 2036 return $this->error( 2037 "Expected no log lines but following line logged: $logLine" 2038 ); 2039 } 2040 $this->trace('No log message received as expected'); 2041 2042 return true; 2043 } 2044 2045 /** 2046 * Expect log config options 2047 * 2048 * @param array $options 2049 * 2050 * @return bool 2051 * @throws \Exception 2052 */ 2053 public function expectLogConfigOptions(array $options) 2054 { 2055 foreach ($options as $value) { 2056 $confValue = str_replace( 2057 ']', 2058 '\]', 2059 str_replace( 2060 '[', 2061 '\[', 2062 str_replace('/', '\/', $value) 2063 ) 2064 ); 2065 $this->expectLogNotice("\s+$confValue", checkAllLogs: true); 2066 } 2067 2068 return true; 2069 } 2070 2071 2072 /** 2073 * Print content of access log. 2074 */ 2075 public function printAccessLog() 2076 { 2077 $accessLog = $this->getFile('acc.log'); 2078 if (is_file($accessLog)) { 2079 print file_get_contents($accessLog); 2080 } 2081 } 2082 2083 /** 2084 * Return content of access log. 2085 * 2086 * @return string|false 2087 */ 2088 public function getAccessLog() 2089 { 2090 $accessLog = $this->getFile('acc.log'); 2091 if (is_file($accessLog)) { 2092 return file_get_contents($accessLog); 2093 } 2094 return false; 2095 } 2096 2097 /** 2098 * Expect a single access log line. 2099 * 2100 * @param string $LogLine 2101 * @param bool $suppressable see expectSuppressableAccessLogEntries 2102 */ 2103 public function expectAccessLog( 2104 string $logLine, 2105 bool $suppressable = false 2106 ) { 2107 if (!$suppressable || $this->expectSuppressableAccessLogEntries) { 2108 $this->expectedAccessLogs[] = $logLine; 2109 } 2110 } 2111 2112 /** 2113 * Checks that all access log entries previously listed as expected by 2114 * calling "expectAccessLog" are in the access log. 2115 */ 2116 public function checkAccessLog() 2117 { 2118 if (isset($this->expectedAccessLogs)) { 2119 $expectedAccessLog = implode("\n", $this->expectedAccessLogs) . "\n"; 2120 } else { 2121 $this->error("Called checkAccessLog but did not previous call expectAccessLog"); 2122 } 2123 if ($accessLog = $this->getAccessLog()) { 2124 if ($expectedAccessLog !== $accessLog) { 2125 $this->error(sprintf( 2126 "Access log was not as expected.\nEXPECTED:\n%s\n\nACTUAL:\n%s", 2127 $expectedAccessLog, 2128 $accessLog 2129 )); 2130 } 2131 } else { 2132 $this->error("Called checkAccessLog but access log does not exist"); 2133 } 2134 } 2135 2136 /** 2137 * Flags whether the access log check should expect to see suppressable 2138 * log entries, i.e. the URL is not in access.suppress_path[] config 2139 * 2140 * @param bool 2141 */ 2142 public function expectSuppressableAccessLogEntries(bool $expectSuppressableAccessLogEntries) 2143 { 2144 $this->expectSuppressableAccessLogEntries = $expectSuppressableAccessLogEntries; 2145 } 2146 2147 /* 2148 * Read all log entries. 2149 * 2150 * @param string $type The log type 2151 * @param string $message The expected message 2152 * @param string|null $pool The pool for pool prefixed log entry 2153 * 2154 * @return bool 2155 * @throws \Exception 2156 */ 2157 public function readAllLogEntries(string $type, string $message, ?string $pool = null): bool 2158 { 2159 return $this->logTool->readAllEntries($type, $message, $pool); 2160 } 2161 2162 /** 2163 * Read all log entries. 2164 * 2165 * @param string $message The expected message 2166 * @param string|null $pool The pool for pool prefixed log entry 2167 * 2168 * @return bool 2169 * @throws \Exception 2170 */ 2171 public function readAllLogNotices(string $message, ?string $pool = null): bool 2172 { 2173 return $this->readAllLogEntries(LogTool::NOTICE, $message, $pool); 2174 } 2175 2176 /** 2177 * Switch the logs source. 2178 * 2179 * @param string $source The source file path or name if log is a pipe. 2180 * 2181 * @throws \Exception 2182 */ 2183 public function switchLogSource(string $source) 2184 { 2185 $this->trace('Switching log descriptor to:', $source); 2186 $this->logReader->setFileSource($source, $this->processTemplate($source)); 2187 } 2188 2189 /** 2190 * Trace execution by printing supplied message only in debug mode. 2191 * 2192 * @param string $title Trace title to print if supplied. 2193 * @param string|array|null $message Message to print. 2194 * @param bool $isCommand Whether message is a command array. 2195 */ 2196 private function trace( 2197 string $title, 2198 string|array|null $message = null, 2199 bool $isCommand = false, 2200 bool $isFile = false 2201 ): void { 2202 if ($this->debug) { 2203 echo "\n"; 2204 echo ">>> $title\n"; 2205 if (is_array($message)) { 2206 if ($isCommand) { 2207 echo implode(' ', $message) . "\n"; 2208 } else { 2209 print_r($message); 2210 } 2211 } elseif ($message !== null) { 2212 if ($isFile) { 2213 $this->logReader->printSeparator(); 2214 } 2215 echo $message . "\n"; 2216 if ($isFile) { 2217 $this->logReader->printSeparator(); 2218 } 2219 } 2220 } 2221 } 2222} 2223