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