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