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