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 ): array { 714 if (is_null($scriptFilename)) { 715 $scriptFilename = $this->makeSourceFile(); 716 } 717 if (is_null($uri)) { 718 $uri = '/' . basename($scriptFilename); 719 } 720 if (is_null($scriptName)) { 721 $scriptName = $uri; 722 } 723 724 $params = array_merge( 725 [ 726 'GATEWAY_INTERFACE' => 'FastCGI/1.0', 727 'REQUEST_METHOD' => is_null($stdin) ? 'GET' : 'POST', 728 'SCRIPT_FILENAME' => $scriptFilename === '' ? null : $scriptFilename, 729 'SCRIPT_NAME' => $scriptName, 730 'QUERY_STRING' => $query, 731 'REQUEST_URI' => $uri . ($query ? '?' . $query : ""), 732 'DOCUMENT_URI' => $uri, 733 'SERVER_SOFTWARE' => 'php/fcgiclient', 734 'REMOTE_ADDR' => '127.0.0.1', 735 'REMOTE_PORT' => '7777', 736 'SERVER_ADDR' => '127.0.0.1', 737 'SERVER_PORT' => '80', 738 'SERVER_NAME' => php_uname('n'), 739 'SERVER_PROTOCOL' => 'HTTP/1.1', 740 'DOCUMENT_ROOT' => __DIR__, 741 'CONTENT_TYPE' => '', 742 'CONTENT_LENGTH' => strlen($stdin ?? "") // Default to 0 743 ], 744 $headers 745 ); 746 747 return array_filter($params, function ($value) { 748 return ! is_null($value); 749 }); 750 } 751 752 /** 753 * Parse stdin and generate data for multipart config. 754 * 755 * @param array $stdin 756 * @param array $headers 757 * 758 * @return void 759 * @throws \Exception 760 */ 761 private function parseStdin(array $stdin, array &$headers) 762 { 763 $parts = $stdin['parts'] ?? null; 764 if (empty($parts)) { 765 throw new \Exception('The stdin array needs to contain parts'); 766 } 767 $boundary = $stdin['boundary'] ?? 'AaB03x'; 768 if ( ! isset($headers['CONTENT_TYPE'])) { 769 $headers['CONTENT_TYPE'] = 'multipart/form-data; boundary=' . $boundary; 770 } 771 $count = $parts['count'] ?? null; 772 if ( ! is_null($count)) { 773 $dispositionType = $parts['disposition'] ?? 'form-data'; 774 $dispositionParam = $parts['param'] ?? 'name'; 775 $namePrefix = $parts['prefix'] ?? 'f'; 776 $nameSuffix = $parts['suffix'] ?? ''; 777 $value = $parts['value'] ?? 'test'; 778 $parts = []; 779 for ($i = 0; $i < $count; $i++) { 780 $parts[] = [ 781 'disposition' => $dispositionType, 782 'param' => $dispositionParam, 783 'name' => "$namePrefix$i$nameSuffix", 784 'value' => $value 785 ]; 786 } 787 } 788 $out = ''; 789 $nl = "\r\n"; 790 foreach ($parts as $part) { 791 if (!is_array($part)) { 792 $part = ['name' => $part]; 793 } elseif ( ! isset($part['name'])) { 794 throw new \Exception('Each part has to have a name'); 795 } 796 $name = $part['name']; 797 $dispositionType = $part['disposition'] ?? 'form-data'; 798 $dispositionParam = $part['param'] ?? 'name'; 799 $value = $part['value'] ?? 'test'; 800 $partHeaders = $part['headers'] ?? []; 801 802 $out .= "--$boundary$nl"; 803 $out .= "Content-disposition: $dispositionType; $dispositionParam=\"$name\"$nl"; 804 foreach ($partHeaders as $headerName => $headerValue) { 805 $out .= "$headerName: $headerValue$nl"; 806 } 807 $out .= $nl; 808 $out .= "$value$nl"; 809 } 810 $out .= "--$boundary--$nl"; 811 812 return $out; 813 } 814 815 /** 816 * Execute request. 817 * 818 * @param string $query 819 * @param array $headers 820 * @param string|null $uri 821 * @param string|null $address 822 * @param string|null $successMessage 823 * @param string|null $errorMessage 824 * @param bool $connKeepAlive 825 * @param bool $socketKeepAlive 826 * @param string|null $scriptFilename = null 827 * @param string|null $scriptName = null 828 * @param string|array|null $stdin = null 829 * @param bool $expectError 830 * @param int $readLimit 831 * @param int $writeDelay 832 * 833 * @return Response 834 * @throws \Exception 835 */ 836 public function request( 837 string $query = '', 838 array $headers = [], 839 string $uri = null, 840 string $address = null, 841 string $successMessage = null, 842 string $errorMessage = null, 843 bool $connKeepAlive = false, 844 bool $socketKeepAlive = false, 845 string $scriptFilename = null, 846 string $scriptName = null, 847 string|array $stdin = null, 848 bool $expectError = false, 849 int $readLimit = -1, 850 int $writeDelay = 0, 851 ): Response { 852 if ($this->hasError()) { 853 return $this->createResponse(expectInvalid: true); 854 } 855 856 if (is_array($stdin)) { 857 $stdin = $this->parseStdin($stdin, $headers); 858 } 859 860 $params = $this->getRequestParams($query, $headers, $uri, $scriptFilename, $scriptName, $stdin); 861 $this->trace('Request params', $params); 862 863 try { 864 $this->response = $this->createResponse( 865 $this->getClient($address, $connKeepAlive, $socketKeepAlive) 866 ->request_data($params, $stdin, $readLimit, $writeDelay) 867 ); 868 if ($expectError) { 869 $this->error('Expected request error but the request was successful'); 870 } else { 871 $this->message($successMessage); 872 } 873 } catch (\Exception $exception) { 874 if ($expectError) { 875 $this->message($successMessage); 876 } elseif ($errorMessage === null) { 877 $this->error("Request failed", $exception); 878 } else { 879 $this->message($errorMessage); 880 } 881 $this->response = $this->createResponse(); 882 } 883 if ($this->debug) { 884 $this->response->debugOutput(); 885 } 886 887 return $this->response; 888 } 889 890 /** 891 * Execute multiple requests in parallel. 892 * 893 * @param int|array $requests 894 * @param string|null $address 895 * @param string|null $successMessage 896 * @param string|null $errorMessage 897 * @param bool $socketKeepAlive 898 * @param bool $connKeepAlive 899 * @param int $readTimeout 900 * @param int $writeDelay 901 * 902 * @return Response[] 903 * @throws \Exception 904 */ 905 public function multiRequest( 906 int|array $requests, 907 string $address = null, 908 string $successMessage = null, 909 string $errorMessage = null, 910 bool $connKeepAlive = false, 911 bool $socketKeepAlive = false, 912 int $readTimeout = 0, 913 int $writeDelay = 0, 914 ) { 915 if (is_numeric($requests)) { 916 $requests = array_fill(0, $requests, []); 917 } 918 919 if ($this->hasError()) { 920 return array_map(fn($request) => $this->createResponse(expectInvalid: true), $requests); 921 } 922 923 try { 924 $connections = array_map( 925 function ($requestData) use ($address, $connKeepAlive, $socketKeepAlive, $writeDelay) { 926 $client = $this->getClient($address, $connKeepAlive, $socketKeepAlive); 927 $params = $this->getRequestParams( 928 $requestData['query'] ?? '', 929 $requestData['headers'] ?? [], 930 $requestData['uri'] ?? null 931 ); 932 933 if (isset($requestData['delay'])) { 934 usleep($requestData['delay']); 935 } 936 937 return [ 938 'client' => $client, 939 'requestId' => $client->async_request($params, false, $writeDelay), 940 ]; 941 }, 942 $requests 943 ); 944 945 $responses = array_map(function ($conn) use ($readTimeout) { 946 $response = $this->createResponse( 947 $conn['client']->wait_for_response_data($conn['requestId'], $readTimeout) 948 ); 949 if ($this->debug) { 950 $response->debugOutput(); 951 } 952 953 return $response; 954 }, $connections); 955 $this->message($successMessage); 956 957 return $responses; 958 } catch (\Exception $exception) { 959 if ($errorMessage === null) { 960 $this->error("Request failed", $exception); 961 } else { 962 $this->message($errorMessage); 963 } 964 965 return array_map(fn($request) => $this->createResponse(expectInvalid: true), $requests); 966 } 967 } 968 969 /** 970 * Execute request for getting FastCGI values. 971 * 972 * @param string|null $address 973 * @param bool $connKeepAlive 974 * @param bool $socketKeepAlive 975 * 976 * @return ValuesResponse 977 * @throws \Exception 978 */ 979 public function requestValues( 980 string $address = null, 981 bool $connKeepAlive = false, 982 bool $socketKeepAlive = false 983 ): ValuesResponse { 984 if ($this->hasError()) { 985 return $this->createValueResponse(); 986 } 987 988 try { 989 $valueResponse = $this->createValueResponse( 990 $this->getClient($address, $connKeepAlive)->getValues(['FCGI_MPXS_CONNS']) 991 ); 992 if ($this->debug) { 993 $this->response->debugOutput(); 994 } 995 } catch (\Exception $exception) { 996 $this->error("Request for getting values failed", $exception); 997 $valueResponse = $this->createValueResponse(); 998 } 999 1000 return $valueResponse; 1001 } 1002 1003 /** 1004 * Get client. 1005 * 1006 * @param string|null $address 1007 * @param bool $connKeepAlive 1008 * @param bool $socketKeepAlive 1009 * 1010 * @return Client 1011 */ 1012 private function getClient( 1013 string $address = null, 1014 bool $connKeepAlive = false, 1015 bool $socketKeepAlive = false 1016 ): Client { 1017 $address = $address ? $this->processTemplate($address) : $this->getAddr(); 1018 if ($address[0] === '/') { // uds 1019 $host = 'unix://' . $address; 1020 $port = -1; 1021 } elseif ($address[0] === '[') { // ipv6 1022 $addressParts = explode(']:', $address); 1023 $host = $addressParts[0]; 1024 if (isset($addressParts[1])) { 1025 $host .= ']'; 1026 $port = $addressParts[1]; 1027 } else { 1028 $port = $this->getPort(); 1029 } 1030 } else { // ipv4 1031 $addressParts = explode(':', $address); 1032 $host = $addressParts[0]; 1033 $port = $addressParts[1] ?? $this->getPort(); 1034 } 1035 1036 if ($socketKeepAlive) { 1037 $connKeepAlive = true; 1038 } 1039 if ( ! $connKeepAlive) { 1040 return new Client($host, $port, $this->createTransport()); 1041 } 1042 1043 if ( ! isset($this->clients[$host][$port])) { 1044 $client = new Client($host, $port, $this->createTransport()); 1045 $client->setKeepAlive($connKeepAlive, $socketKeepAlive); 1046 $this->clients[$host][$port] = $client; 1047 } 1048 1049 return $this->clients[$host][$port]; 1050 } 1051 1052 /** 1053 * @return string 1054 */ 1055 public function getUser() 1056 { 1057 return get_current_user(); 1058 } 1059 1060 /** 1061 * @return string 1062 */ 1063 public function getGroup() 1064 { 1065 return get_current_group(); 1066 } 1067 1068 /** 1069 * @return int 1070 */ 1071 public function getUid() 1072 { 1073 return getmyuid(); 1074 } 1075 1076 /** 1077 * @return int 1078 */ 1079 public function getGid() 1080 { 1081 return getmygid(); 1082 } 1083 1084 /** 1085 * Reload FPM by sending USR2 signal and optionally change config before that. 1086 * 1087 * @param string|array $configTemplate 1088 * 1089 * @return string 1090 * @throws \Exception 1091 */ 1092 public function reload($configTemplate = null) 1093 { 1094 if ( ! is_null($configTemplate)) { 1095 self::cleanConfigFiles(); 1096 $this->configTemplate = $configTemplate; 1097 $this->createConfig(); 1098 } 1099 1100 return $this->signal('USR2'); 1101 } 1102 1103 /** 1104 * Reload FPM logs by sending USR1 signal. 1105 * 1106 * @return string 1107 * @throws \Exception 1108 */ 1109 public function reloadLogs(): string 1110 { 1111 return $this->signal('USR1'); 1112 } 1113 1114 /** 1115 * Send signal to the supplied PID or the server PID. 1116 * 1117 * @param string $signal 1118 * @param int|null $pid 1119 * 1120 * @return string 1121 */ 1122 public function signal($signal, int $pid = null) 1123 { 1124 if (is_null($pid)) { 1125 $pid = $this->getPid(); 1126 } 1127 $cmd = "kill -$signal $pid"; 1128 $this->trace('Sending signal using command', $cmd, true); 1129 1130 return exec("kill -$signal $pid"); 1131 } 1132 1133 /** 1134 * Terminate master process 1135 */ 1136 public function terminate() 1137 { 1138 if ($this->daemonized) { 1139 $this->signal('TERM'); 1140 } else { 1141 proc_terminate($this->masterProcess); 1142 } 1143 } 1144 1145 /** 1146 * Close all open descriptors and process resources 1147 * 1148 * @param bool $terminate 1149 */ 1150 public function close($terminate = false) 1151 { 1152 if ($terminate) { 1153 $this->terminate(); 1154 } 1155 proc_close($this->masterProcess); 1156 } 1157 1158 /** 1159 * Create a config file. 1160 * 1161 * @param string $extension 1162 * 1163 * @return string 1164 * @throws \Exception 1165 */ 1166 private function createConfig($extension = 'ini') 1167 { 1168 if (is_array($this->configTemplate)) { 1169 $configTemplates = $this->configTemplate; 1170 if ( ! isset($configTemplates['main'])) { 1171 throw new \Exception('The config template array has to have main config'); 1172 } 1173 $mainTemplate = $configTemplates['main']; 1174 if ( ! is_dir(self::CONF_DIR)) { 1175 mkdir(self::CONF_DIR); 1176 } 1177 foreach ($this->createPoolConfigs($configTemplates) as $name => $poolConfig) { 1178 $this->makeFile( 1179 'conf', 1180 $this->processTemplate($poolConfig), 1181 self::CONF_DIR, 1182 $name 1183 ); 1184 } 1185 } else { 1186 $mainTemplate = $this->configTemplate; 1187 } 1188 1189 return $this->makeFile($extension, $this->processTemplate($mainTemplate)); 1190 } 1191 1192 /** 1193 * Create pool config templates. 1194 * 1195 * @param array $configTemplates 1196 * 1197 * @return array 1198 * @throws \Exception 1199 */ 1200 private function createPoolConfigs(array $configTemplates) 1201 { 1202 if ( ! isset($configTemplates['poolTemplate'])) { 1203 unset($configTemplates['main']); 1204 1205 return $configTemplates; 1206 } 1207 $poolTemplate = $configTemplates['poolTemplate']; 1208 $configs = []; 1209 if (isset($configTemplates['count'])) { 1210 $start = $configTemplates['start'] ?? 1; 1211 for ($i = $start; $i < $start + $configTemplates['count']; $i++) { 1212 $configs[$i] = str_replace('%index%', $i, $poolTemplate); 1213 } 1214 } elseif (isset($configTemplates['names'])) { 1215 foreach ($configTemplates['names'] as $name) { 1216 $configs[$name] = str_replace('%name%', $name, $poolTemplate); 1217 } 1218 } else { 1219 throw new \Exception('The config template requires count or names if poolTemplate set'); 1220 } 1221 1222 return $configs; 1223 } 1224 1225 /** 1226 * Process template string. 1227 * 1228 * @param string $template 1229 * 1230 * @return string 1231 */ 1232 private function processTemplate(string $template) 1233 { 1234 $vars = [ 1235 'FILE:LOG:ACC' => ['getAbsoluteFile', self::FILE_EXT_LOG_ACC], 1236 'FILE:LOG:ERR' => ['getAbsoluteFile', self::FILE_EXT_LOG_ERR], 1237 'FILE:LOG:SLOW' => ['getAbsoluteFile', self::FILE_EXT_LOG_SLOW], 1238 'FILE:PID' => ['getAbsoluteFile', self::FILE_EXT_PID], 1239 'RFILE:LOG:ACC' => ['getRelativeFile', self::FILE_EXT_LOG_ACC], 1240 'RFILE:LOG:ERR' => ['getRelativeFile', self::FILE_EXT_LOG_ERR], 1241 'RFILE:LOG:SLOW' => ['getRelativeFile', self::FILE_EXT_LOG_SLOW], 1242 'RFILE:PID' => ['getRelativeFile', self::FILE_EXT_PID], 1243 'ADDR:IPv4' => ['getAddr', 'ipv4'], 1244 'ADDR:IPv4:ANY' => ['getAddr', 'ipv4-any'], 1245 'ADDR:IPv6' => ['getAddr', 'ipv6'], 1246 'ADDR:IPv6:ANY' => ['getAddr', 'ipv6-any'], 1247 'ADDR:UDS' => ['getAddr', 'uds'], 1248 'PORT' => ['getPort', 'ip'], 1249 'INCLUDE:CONF' => self::CONF_DIR . '/*.conf', 1250 'USER' => ['getUser'], 1251 'GROUP' => ['getGroup'], 1252 'UID' => ['getUid'], 1253 'GID' => ['getGid'], 1254 'MASTER:OUT' => 'pipe:1', 1255 'STDERR' => '/dev/stderr', 1256 'STDOUT' => '/dev/stdout', 1257 ]; 1258 $aliases = [ 1259 'ADDR' => 'ADDR:IPv4', 1260 'FILE:LOG' => 'FILE:LOG:ERR', 1261 ]; 1262 foreach ($aliases as $aliasName => $aliasValue) { 1263 $vars[$aliasName] = $vars[$aliasValue]; 1264 } 1265 1266 return preg_replace_callback( 1267 '/{{([a-zA-Z0-9:]+)(\[\w+\])?}}/', 1268 function ($matches) use ($vars) { 1269 $varName = $matches[1]; 1270 if ( ! isset($vars[$varName])) { 1271 $this->error("Invalid config variable $varName"); 1272 1273 return 'INVALID'; 1274 } 1275 $pool = $matches[2] ?? 'default'; 1276 $varValue = $vars[$varName]; 1277 if (is_string($varValue)) { 1278 return $varValue; 1279 } 1280 $functionName = array_shift($varValue); 1281 $varValue[] = $pool; 1282 1283 return call_user_func_array([$this, $functionName], $varValue); 1284 }, 1285 $template 1286 ); 1287 } 1288 1289 /** 1290 * @param string $type 1291 * @param string $pool 1292 * 1293 * @return string 1294 */ 1295 public function getAddr(string $type = 'ipv4', $pool = 'default') 1296 { 1297 $port = $this->getPort($type, $pool, true); 1298 if ($type === 'uds') { 1299 $address = $this->getFile($port . '.sock'); 1300 1301 // Socket max path length is 108 on Linux and 104 on BSD, 1302 // so we use the latter 1303 if (strlen($address) <= 104) { 1304 return $address; 1305 } 1306 1307 $addressPart = hash('crc32', dirname($address)) . '-' . basename($address); 1308 1309 // is longer on Mac, than on Linux 1310 $tmpDirAddress = sys_get_temp_dir() . '/' . $addressPart; 1311 ; 1312 1313 if (strlen($tmpDirAddress) <= 104) { 1314 return $tmpDirAddress; 1315 } 1316 1317 $srcRootAddress = dirname(__DIR__, 3) . '/' . $addressPart; 1318 1319 return $srcRootAddress; 1320 } 1321 1322 return $this->getHost($type) . ':' . $port; 1323 } 1324 1325 /** 1326 * @param string $type 1327 * @param string $pool 1328 * @param bool $useAsId 1329 * 1330 * @return int 1331 */ 1332 public function getPort(string $type = 'ip', $pool = 'default', $useAsId = false) 1333 { 1334 if ($type === 'uds' && ! $useAsId) { 1335 return -1; 1336 } 1337 1338 if (isset($this->ports['values'][$pool])) { 1339 return $this->ports['values'][$pool]; 1340 } 1341 $port = ($this->ports['last'] ?? 9000 + PHP_INT_SIZE - 1) + 1; 1342 $this->ports['values'][$pool] = $this->ports['last'] = $port; 1343 1344 return $port; 1345 } 1346 1347 /** 1348 * @param string $type 1349 * 1350 * @return string 1351 */ 1352 public function getHost(string $type = 'ipv4') 1353 { 1354 switch ($type) { 1355 case 'ipv6-any': 1356 return '[::]'; 1357 case 'ipv6': 1358 return '[::1]'; 1359 case 'ipv4-any': 1360 return '0.0.0.0'; 1361 default: 1362 return '127.0.0.1'; 1363 } 1364 } 1365 1366 /** 1367 * Get listen address. 1368 * 1369 * @param string|null $template 1370 * 1371 * @return string 1372 */ 1373 public function getListen($template = null) 1374 { 1375 return $template ? $this->processTemplate($template) : $this->getAddr(); 1376 } 1377 1378 /** 1379 * Get PID. 1380 * 1381 * @return int 1382 */ 1383 public function getPid() 1384 { 1385 $pidFile = $this->getFile('pid'); 1386 if ( ! is_file($pidFile)) { 1387 return (int)$this->error("PID file has not been created"); 1388 } 1389 $pidContent = file_get_contents($pidFile); 1390 if ( ! is_numeric($pidContent)) { 1391 return (int)$this->error("PID content '$pidContent' is not integer"); 1392 } 1393 $this->trace('PID found', $pidContent); 1394 1395 return (int)$pidContent; 1396 } 1397 1398 1399 /** 1400 * Get file path for resource file. 1401 * 1402 * @param string $extension 1403 * @param string|null $dir 1404 * @param string|null $name 1405 * 1406 * @return string 1407 */ 1408 private function getFile(string $extension, string $dir = null, string $name = null): string 1409 { 1410 $fileName = (is_null($name) ? $this->fileName : $name . '.') . $extension; 1411 1412 return is_null($dir) ? $fileName : $dir . '/' . $fileName; 1413 } 1414 1415 /** 1416 * Get absolute file path for the resource file used by templates. 1417 * 1418 * @param string $extension 1419 * 1420 * @return string 1421 */ 1422 private function getAbsoluteFile(string $extension): string 1423 { 1424 return $this->getFile($extension); 1425 } 1426 1427 /** 1428 * Get relative file name for resource file used by templates. 1429 * 1430 * @param string $extension 1431 * 1432 * @return string 1433 */ 1434 private function getRelativeFile(string $extension): string 1435 { 1436 $fileName = rtrim(basename($this->fileName), '.'); 1437 1438 return $this->getFile($extension, null, $fileName); 1439 } 1440 1441 /** 1442 * Get prefixed file. 1443 * 1444 * @param string $extension 1445 * @param string|null $prefix 1446 * 1447 * @return string 1448 */ 1449 public function getPrefixedFile(string $extension, string $prefix = null): string 1450 { 1451 $fileName = rtrim($this->fileName, '.'); 1452 if ( ! is_null($prefix)) { 1453 $fileName = $prefix . '/' . basename($fileName); 1454 } 1455 1456 return $this->getFile($extension, null, $fileName); 1457 } 1458 1459 /** 1460 * Create a resource file. 1461 * 1462 * @param string $extension 1463 * @param string $content 1464 * @param string|null $dir 1465 * @param string|null $name 1466 * 1467 * @return string 1468 */ 1469 private function makeFile( 1470 string $extension, 1471 string $content = '', 1472 string $dir = null, 1473 string $name = null, 1474 bool $overwrite = true 1475 ): string { 1476 $filePath = $this->getFile($extension, $dir, $name); 1477 if ( ! $overwrite && is_file($filePath)) { 1478 return $filePath; 1479 } 1480 file_put_contents($filePath, $content); 1481 1482 $this->trace('Created file: ' . $filePath, $content, isFile: true); 1483 1484 return $filePath; 1485 } 1486 1487 /** 1488 * Create a source code file. 1489 * 1490 * @return string 1491 */ 1492 public function makeSourceFile(): string 1493 { 1494 return $this->makeFile('src.php', $this->code, overwrite: false); 1495 } 1496 1497 /** 1498 * Create a source file and script name. 1499 * 1500 * @return string[] 1501 */ 1502 public function createSourceFileAndScriptName(): array 1503 { 1504 $sourceFile = $this->makeFile('src.php', $this->code, overwrite: false); 1505 1506 return [$sourceFile, '/' . basename($sourceFile)]; 1507 } 1508 1509 /** 1510 * Create a new response. 1511 * 1512 * @param mixed $data 1513 * @param bool $expectInvalid 1514 * @return Response 1515 */ 1516 private function createResponse($data = null, bool $expectInvalid = false): Response 1517 { 1518 return new Response($this, $data, $expectInvalid); 1519 } 1520 1521 /** 1522 * Create a new values response. 1523 * 1524 * @param mixed $values 1525 * @return ValuesResponse 1526 * @throws \Exception 1527 */ 1528 private function createValueResponse($values = null): ValuesResponse 1529 { 1530 return new ValuesResponse($this, $values); 1531 } 1532 1533 /** 1534 * @param string|null $msg 1535 */ 1536 private function message($msg) 1537 { 1538 if ($msg !== null) { 1539 echo "$msg\n"; 1540 } 1541 } 1542 1543 /** 1544 * Print log reader logs. 1545 * 1546 * @return void 1547 */ 1548 public function printLogs(): void 1549 { 1550 $this->logReader->printLogs(); 1551 } 1552 1553 /** 1554 * Display error. 1555 * 1556 * @param string $msg Error message. 1557 * @param \Exception|null $exception If there is an exception, log its message 1558 * @param bool $prefix Whether to prefix the error message 1559 * 1560 * @return false 1561 */ 1562 private function error(string $msg, \Exception $exception = null, bool $prefix = true): bool 1563 { 1564 $this->error = $prefix ? 'ERROR: ' . $msg : ltrim($msg); 1565 if ($exception) { 1566 $this->error .= '; EXCEPTION: ' . $exception->getMessage(); 1567 } 1568 $this->error .= "\n"; 1569 1570 echo $this->error; 1571 $this->printLogs(); 1572 1573 return false; 1574 } 1575 1576 /** 1577 * Check whether any error was set. 1578 * 1579 * @return bool 1580 */ 1581 private function hasError() 1582 { 1583 return ! is_null($this->error) || ! is_null($this->logTool->getError()); 1584 } 1585 1586 /** 1587 * Expect file with a supplied extension to exist. 1588 * 1589 * @param string $extension 1590 * @param string $prefix 1591 * 1592 * @return bool 1593 */ 1594 public function expectFile(string $extension, $prefix = null) 1595 { 1596 $filePath = $this->getPrefixedFile($extension, $prefix); 1597 if ( ! file_exists($filePath)) { 1598 return $this->error("The file $filePath does not exist"); 1599 } 1600 $this->trace('File path exists as expected', $filePath); 1601 1602 return true; 1603 } 1604 1605 /** 1606 * Expect file with a supplied extension to not exist. 1607 * 1608 * @param string $extension 1609 * @param string $prefix 1610 * 1611 * @return bool 1612 */ 1613 public function expectNoFile(string $extension, $prefix = null) 1614 { 1615 $filePath = $this->getPrefixedFile($extension, $prefix); 1616 if (file_exists($filePath)) { 1617 return $this->error("The file $filePath exists"); 1618 } 1619 $this->trace('File path does not exist as expected', $filePath); 1620 1621 return true; 1622 } 1623 1624 /** 1625 * Expect message to be written to FastCGI error stream. 1626 * 1627 * @param string $message 1628 * @param int $limit 1629 * @param int $repeat 1630 */ 1631 public function expectFastCGIErrorMessage( 1632 string $message, 1633 int $limit = 1024, 1634 int $repeat = 0 1635 ) { 1636 $this->logTool->setExpectedMessage($message, $limit, $repeat); 1637 $this->logTool->checkTruncatedMessage($this->response->getErrorData()); 1638 } 1639 1640 /** 1641 * Expect log to be empty. 1642 * 1643 * @throws \Exception 1644 */ 1645 public function expectLogEmpty() 1646 { 1647 try { 1648 $line = $this->logReader->getLine(1, 0, true); 1649 if ($line === '') { 1650 $line = $this->logReader->getLine(1, 0, true); 1651 } 1652 if ($line !== null) { 1653 $this->error('Log is not closed and returned line: ' . $line); 1654 } 1655 } catch (LogTimoutException $exception) { 1656 $this->error('Log is not closed and timed out', $exception); 1657 } 1658 } 1659 1660 /** 1661 * Expect reloading lines to be logged. 1662 * 1663 * @param int $socketCount 1664 * @param bool $expectInitialProgressMessage 1665 * @param bool $expectReloadingMessage 1666 * 1667 * @throws \Exception 1668 */ 1669 public function expectLogReloadingNotices( 1670 int $socketCount = 1, 1671 bool $expectInitialProgressMessage = true, 1672 bool $expectReloadingMessage = true 1673 ) { 1674 $this->logTool->expectReloadingLines( 1675 $socketCount, 1676 $expectInitialProgressMessage, 1677 $expectReloadingMessage 1678 ); 1679 } 1680 1681 /** 1682 * Expect reloading lines to be logged. 1683 * 1684 * @throws \Exception 1685 */ 1686 public function expectLogReloadingLogsNotices() 1687 { 1688 $this->logTool->expectReloadingLogsLines(); 1689 } 1690 1691 /** 1692 * Expect starting lines to be logged. 1693 * @throws \Exception 1694 */ 1695 public function expectLogStartNotices() 1696 { 1697 $this->logTool->expectStartingLines(); 1698 } 1699 1700 /** 1701 * Expect terminating lines to be logged. 1702 * @throws \Exception 1703 */ 1704 public function expectLogTerminatingNotices() 1705 { 1706 $this->logTool->expectTerminatorLines(); 1707 } 1708 1709 /** 1710 * Expect log pattern in logs. 1711 * 1712 * @param string $pattern Log pattern 1713 * @param bool $checkAllLogs Whether to also check past logs. 1714 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1715 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1716 * 1717 * @throws \Exception 1718 */ 1719 public function expectLogPattern( 1720 string $pattern, 1721 bool $checkAllLogs = false, 1722 int $timeoutSeconds = null, 1723 int $timeoutMicroseconds = null, 1724 ) { 1725 $this->logTool->expectPattern( 1726 $pattern, 1727 false, 1728 $checkAllLogs, 1729 $timeoutSeconds, 1730 $timeoutMicroseconds 1731 ); 1732 } 1733 1734 /** 1735 * Expect no such log pattern in logs. 1736 * 1737 * @param string $pattern Log pattern 1738 * @param bool $checkAllLogs Whether to also check past logs. 1739 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1740 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1741 * 1742 * @throws \Exception 1743 */ 1744 public function expectNoLogPattern( 1745 string $pattern, 1746 bool $checkAllLogs = true, 1747 int $timeoutSeconds = null, 1748 int $timeoutMicroseconds = null, 1749 ) { 1750 if (is_null($timeoutSeconds) && is_null($timeoutMicroseconds)) { 1751 $timeoutMicroseconds = 10; 1752 } 1753 $this->logTool->expectPattern( 1754 $pattern, 1755 true, 1756 $checkAllLogs, 1757 $timeoutSeconds, 1758 $timeoutMicroseconds 1759 ); 1760 } 1761 1762 /** 1763 * Expect log message that can span multiple lines. 1764 * 1765 * @param string $message 1766 * @param int $limit 1767 * @param int $repeat 1768 * @param bool $decorated 1769 * @param bool $wrapped 1770 * 1771 * @throws \Exception 1772 */ 1773 public function expectLogMessage( 1774 string $message, 1775 int $limit = 1024, 1776 int $repeat = 0, 1777 bool $decorated = true, 1778 bool $wrapped = true 1779 ) { 1780 $this->logTool->setExpectedMessage($message, $limit, $repeat); 1781 if ($wrapped) { 1782 $this->logTool->checkWrappedMessage(true, $decorated); 1783 } else { 1784 $this->logTool->checkTruncatedMessage(); 1785 } 1786 } 1787 1788 /** 1789 * Expect a single log line. 1790 * 1791 * @param string $message The expected message. 1792 * @param bool $isStdErr Whether it is logged to stderr. 1793 * @param bool $decorated Whether the log lines are decorated. 1794 * 1795 * @return bool 1796 * @throws \Exception 1797 */ 1798 public function expectLogLine( 1799 string $message, 1800 bool $isStdErr = true, 1801 bool $decorated = true 1802 ): bool { 1803 $messageLen = strlen($message); 1804 $limit = $messageLen > 1024 ? $messageLen + 16 : 1024; 1805 $this->logTool->setExpectedMessage($message, $limit); 1806 1807 return $this->logTool->checkWrappedMessage(false, $decorated, $isStdErr); 1808 } 1809 1810 /** 1811 * Expect log entry. 1812 * 1813 * @param string $type The log type. 1814 * @param string $message The expected message. 1815 * @param string|null $pool The pool for pool prefixed log entry. 1816 * @param int $count The number of items. 1817 * @param bool $checkAllLogs Whether to also check past logs. 1818 * @param bool $invert Whether the log entry is not expected rather than expected. 1819 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1820 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1821 * @param string $ignoreErrorFor Ignore error for supplied string in the message. 1822 * 1823 * @return bool 1824 * @throws \Exception 1825 */ 1826 private function expectLogEntry( 1827 string $type, 1828 string $message, 1829 string $pool = null, 1830 int $count = 1, 1831 bool $checkAllLogs = false, 1832 bool $invert = false, 1833 int $timeoutSeconds = null, 1834 int $timeoutMicroseconds = null, 1835 string $ignoreErrorFor = LogTool::DEBUG 1836 ): bool { 1837 for ($i = 0; $i < $count; $i++) { 1838 $result = $this->logTool->expectEntry( 1839 $type, 1840 $message, 1841 $pool, 1842 $ignoreErrorFor, 1843 $checkAllLogs, 1844 $invert, 1845 $timeoutSeconds, 1846 $timeoutMicroseconds, 1847 ); 1848 1849 if ( ! $result) { 1850 return false; 1851 } 1852 } 1853 1854 return true; 1855 } 1856 1857 /** 1858 * Expect a log debug message. 1859 * 1860 * @param string $message The expected message. 1861 * @param string|null $pool The pool for pool prefixed log entry. 1862 * @param int $count The number of items. 1863 * @param bool $checkAllLogs Whether to also check past logs. 1864 * @param bool $invert Whether the log entry is not expected rather than expected. 1865 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1866 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1867 * 1868 * @return bool 1869 * @throws \Exception 1870 */ 1871 public function expectLogDebug( 1872 string $message, 1873 string $pool = null, 1874 int $count = 1, 1875 bool $checkAllLogs = false, 1876 bool $invert = false, 1877 int $timeoutSeconds = null, 1878 int $timeoutMicroseconds = null 1879 ): bool { 1880 return $this->expectLogEntry( 1881 LogTool::DEBUG, 1882 $message, 1883 $pool, 1884 $count, 1885 $checkAllLogs, 1886 $invert, 1887 $timeoutSeconds, 1888 $timeoutMicroseconds, 1889 LogTool::ERROR 1890 ); 1891 } 1892 1893 /** 1894 * Expect a log notice. 1895 * 1896 * @param string $message The expected message. 1897 * @param string|null $pool The pool for pool prefixed log entry. 1898 * @param int $count The number of items. 1899 * @param bool $checkAllLogs Whether to also check past logs. 1900 * @param bool $invert Whether the log entry is not expected rather than expected. 1901 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1902 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1903 * 1904 * @return bool 1905 * @throws \Exception 1906 */ 1907 public function expectLogNotice( 1908 string $message, 1909 string $pool = null, 1910 int $count = 1, 1911 bool $checkAllLogs = false, 1912 bool $invert = false, 1913 int $timeoutSeconds = null, 1914 int $timeoutMicroseconds = null 1915 ): bool { 1916 return $this->expectLogEntry( 1917 LogTool::NOTICE, 1918 $message, 1919 $pool, 1920 $count, 1921 $checkAllLogs, 1922 $invert, 1923 $timeoutSeconds, 1924 $timeoutMicroseconds 1925 ); 1926 } 1927 1928 /** 1929 * Expect a log warning. 1930 * 1931 * @param string $message The expected message. 1932 * @param string|null $pool The pool for pool prefixed log entry. 1933 * @param int $count The number of items. 1934 * @param bool $checkAllLogs Whether to also check past logs. 1935 * @param bool $invert Whether the log entry is not expected rather than expected. 1936 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1937 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1938 * 1939 * @return bool 1940 * @throws \Exception 1941 */ 1942 public function expectLogWarning( 1943 string $message, 1944 string $pool = null, 1945 int $count = 1, 1946 bool $checkAllLogs = false, 1947 bool $invert = false, 1948 int $timeoutSeconds = null, 1949 int $timeoutMicroseconds = null 1950 ): bool { 1951 return $this->expectLogEntry( 1952 LogTool::WARNING, 1953 $message, 1954 $pool, 1955 $count, 1956 $checkAllLogs, 1957 $invert, 1958 $timeoutSeconds, 1959 $timeoutMicroseconds 1960 ); 1961 } 1962 1963 /** 1964 * Expect a log error. 1965 * 1966 * @param string $message The expected message. 1967 * @param string|null $pool The pool for pool prefixed log entry. 1968 * @param int $count The number of items. 1969 * @param bool $checkAllLogs Whether to also check past logs. 1970 * @param bool $invert Whether the log entry is not expected rather than expected. 1971 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 1972 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 1973 * 1974 * @return bool 1975 * @throws \Exception 1976 */ 1977 public function expectLogError( 1978 string $message, 1979 string $pool = null, 1980 int $count = 1, 1981 bool $checkAllLogs = false, 1982 bool $invert = false, 1983 int $timeoutSeconds = null, 1984 int $timeoutMicroseconds = null 1985 ): bool { 1986 return $this->expectLogEntry( 1987 LogTool::ERROR, 1988 $message, 1989 $pool, 1990 $count, 1991 $checkAllLogs, 1992 $invert, 1993 $timeoutSeconds, 1994 $timeoutMicroseconds 1995 ); 1996 } 1997 1998 /** 1999 * Expect a log alert. 2000 * 2001 * @param string $message The expected message. 2002 * @param string|null $pool The pool for pool prefixed log entry. 2003 * @param int $count The number of items. 2004 * @param bool $checkAllLogs Whether to also check past logs. 2005 * @param bool $invert Whether the log entry is not expected rather than expected. 2006 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 2007 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 2008 * 2009 * @return bool 2010 * @throws \Exception 2011 */ 2012 public function expectLogAlert( 2013 string $message, 2014 string $pool = null, 2015 int $count = 1, 2016 bool $checkAllLogs = false, 2017 bool $invert = false, 2018 int $timeoutSeconds = null, 2019 int $timeoutMicroseconds = null 2020 ): bool { 2021 return $this->expectLogEntry( 2022 LogTool::ALERT, 2023 $message, 2024 $pool, 2025 $count, 2026 $checkAllLogs, 2027 $invert, 2028 $timeoutSeconds, 2029 $timeoutMicroseconds 2030 ); 2031 } 2032 2033 /** 2034 * Expect no log lines to be logged. 2035 * 2036 * @return bool 2037 * @throws \Exception 2038 */ 2039 public function expectNoLogMessages(): bool 2040 { 2041 $logLine = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000); 2042 if ($logLine === "") { 2043 $logLine = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000); 2044 } 2045 if ($logLine !== null) { 2046 return $this->error( 2047 "Expected no log lines but following line logged: $logLine" 2048 ); 2049 } 2050 $this->trace('No log message received as expected'); 2051 2052 return true; 2053 } 2054 2055 /** 2056 * Expect log config options 2057 * 2058 * @param array $options 2059 * 2060 * @return bool 2061 * @throws \Exception 2062 */ 2063 public function expectLogConfigOptions(array $options) 2064 { 2065 foreach ($options as $value) { 2066 $confValue = str_replace( 2067 ']', 2068 '\]', 2069 str_replace( 2070 '[', 2071 '\[', 2072 str_replace('/', '\/', $value) 2073 ) 2074 ); 2075 $this->expectLogNotice("\s+$confValue", checkAllLogs: true); 2076 } 2077 2078 return true; 2079 } 2080 2081 2082 /** 2083 * Print content of access log. 2084 */ 2085 public function printAccessLog() 2086 { 2087 $accessLog = $this->getFile('acc.log'); 2088 if (is_file($accessLog)) { 2089 print file_get_contents($accessLog); 2090 } 2091 } 2092 2093 /** 2094 * Return content of access log. 2095 * 2096 * @return string|false 2097 */ 2098 public function getAccessLog() 2099 { 2100 $accessLog = $this->getFile('acc.log'); 2101 if (is_file($accessLog)) { 2102 return file_get_contents($accessLog); 2103 } 2104 return false; 2105 } 2106 2107 /** 2108 * Expect a single access log line. 2109 * 2110 * @param string $LogLine 2111 * @param bool $suppressable see expectSuppressableAccessLogEntries 2112 */ 2113 public function expectAccessLog( 2114 string $logLine, 2115 bool $suppressable = false 2116 ) { 2117 if (!$suppressable || $this->expectSuppressableAccessLogEntries) { 2118 $this->expectedAccessLogs[] = $logLine; 2119 } 2120 } 2121 2122 /** 2123 * Checks that all access log entries previously listed as expected by 2124 * calling "expectAccessLog" are in the access log. 2125 */ 2126 public function checkAccessLog() 2127 { 2128 if (isset($this->expectedAccessLogs)) { 2129 $expectedAccessLog = implode("\n", $this->expectedAccessLogs) . "\n"; 2130 } else { 2131 $this->error("Called checkAccessLog but did not previous call expectAccessLog"); 2132 } 2133 if ($accessLog = $this->getAccessLog()) { 2134 if ($expectedAccessLog !== $accessLog) { 2135 $this->error(sprintf( 2136 "Access log was not as expected.\nEXPECTED:\n%s\n\nACTUAL:\n%s", 2137 $expectedAccessLog, 2138 $accessLog 2139 )); 2140 } 2141 } else { 2142 $this->error("Called checkAccessLog but access log does not exist"); 2143 } 2144 } 2145 2146 /** 2147 * Flags whether the access log check should expect to see suppressable 2148 * log entries, i.e. the URL is not in access.suppress_path[] config 2149 * 2150 * @param bool 2151 */ 2152 public function expectSuppressableAccessLogEntries(bool $expectSuppressableAccessLogEntries) 2153 { 2154 $this->expectSuppressableAccessLogEntries = $expectSuppressableAccessLogEntries; 2155 } 2156 2157 /* 2158 * Read all log entries. 2159 * 2160 * @param string $type The log type 2161 * @param string $message The expected message 2162 * @param string|null $pool The pool for pool prefixed log entry 2163 * 2164 * @return bool 2165 * @throws \Exception 2166 */ 2167 public function readAllLogEntries(string $type, string $message, string $pool = null): bool 2168 { 2169 return $this->logTool->readAllEntries($type, $message, $pool); 2170 } 2171 2172 /** 2173 * Read all log entries. 2174 * 2175 * @param string $message The expected message 2176 * @param string|null $pool The pool for pool prefixed log entry 2177 * 2178 * @return bool 2179 * @throws \Exception 2180 */ 2181 public function readAllLogNotices(string $message, string $pool = null): bool 2182 { 2183 return $this->readAllLogEntries(LogTool::NOTICE, $message, $pool); 2184 } 2185 2186 /** 2187 * Switch the logs source. 2188 * 2189 * @param string $source The source file path or name if log is a pipe. 2190 * 2191 * @throws \Exception 2192 */ 2193 public function switchLogSource(string $source) 2194 { 2195 $this->trace('Switching log descriptor to:', $source); 2196 $this->logReader->setFileSource($source, $this->processTemplate($source)); 2197 } 2198 2199 /** 2200 * Trace execution by printing supplied message only in debug mode. 2201 * 2202 * @param string $title Trace title to print if supplied. 2203 * @param string|array|null $message Message to print. 2204 * @param bool $isCommand Whether message is a command array. 2205 */ 2206 private function trace( 2207 string $title, 2208 string|array $message = null, 2209 bool $isCommand = false, 2210 bool $isFile = false 2211 ): void { 2212 if ($this->debug) { 2213 echo "\n"; 2214 echo ">>> $title\n"; 2215 if (is_array($message)) { 2216 if ($isCommand) { 2217 echo implode(' ', $message) . "\n"; 2218 } else { 2219 print_r($message); 2220 } 2221 } elseif ($message !== null) { 2222 if ($isFile) { 2223 $this->logReader->printSeparator(); 2224 } 2225 echo $message . "\n"; 2226 if ($isFile) { 2227 $this->logReader->printSeparator(); 2228 } 2229 } 2230 } 2231 } 2232} 2233