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