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