1<?php 2 3namespace FPM; 4 5use Adoy\FastCGI\Client; 6 7require_once 'fcgi.inc'; 8require_once 'logtool.inc'; 9require_once 'response.inc'; 10 11class Tester 12{ 13 /** 14 * Config directory for included files. 15 */ 16 const CONF_DIR = __DIR__ . '/conf.d'; 17 18 /** 19 * File extension for access log. 20 */ 21 const FILE_EXT_LOG_ACC = 'acc.log'; 22 23 /** 24 * File extension for error log. 25 */ 26 const FILE_EXT_LOG_ERR = 'err.log'; 27 28 /** 29 * File extension for slow log. 30 */ 31 const FILE_EXT_LOG_SLOW = 'slow.log'; 32 33 /** 34 * File extension for PID file. 35 */ 36 const FILE_EXT_PID = 'pid'; 37 38 /** 39 * @var array 40 */ 41 static private $supportedFiles = [ 42 self::FILE_EXT_LOG_ACC, 43 self::FILE_EXT_LOG_ERR, 44 self::FILE_EXT_LOG_SLOW, 45 self::FILE_EXT_PID, 46 'src.php', 47 'ini', 48 'skip.ini', 49 '*.sock', 50 ]; 51 52 /** 53 * @var array 54 */ 55 static private $filesToClean = ['.user.ini']; 56 57 /** 58 * @var bool 59 */ 60 private $debug; 61 62 /** 63 * @var array 64 */ 65 private $clients; 66 67 /** 68 * @var LogTool 69 */ 70 private $logTool; 71 72 /** 73 * Configuration template 74 * 75 * @var string|array 76 */ 77 private $configTemplate; 78 79 /** 80 * The PHP code to execute 81 * 82 * @var string 83 */ 84 private $code; 85 86 /** 87 * @var array 88 */ 89 private $options; 90 91 /** 92 * @var string 93 */ 94 private $fileName; 95 96 /** 97 * @var resource 98 */ 99 private $masterProcess; 100 101 /** 102 * @var resource 103 */ 104 private $outDesc; 105 106 /** 107 * @var array 108 */ 109 private $ports = []; 110 111 /** 112 * @var string 113 */ 114 private $error; 115 116 /** 117 * The last response for the request call 118 * 119 * @var Response 120 */ 121 private $response; 122 123 /** 124 * Clean all the created files up 125 * 126 * @param int $backTraceIndex 127 */ 128 static public function clean($backTraceIndex = 1) 129 { 130 $filePrefix = self::getCallerFileName($backTraceIndex); 131 if (substr($filePrefix, -6) === 'clean.') { 132 $filePrefix = substr($filePrefix, 0, -6); 133 } 134 135 $filesToClean = array_merge( 136 array_map( 137 function($fileExtension) use ($filePrefix) { 138 return $filePrefix . $fileExtension; 139 }, 140 self::$supportedFiles 141 ), 142 array_map( 143 function($fileExtension) { 144 return __DIR__ . '/' . $fileExtension; 145 }, 146 self::$filesToClean 147 ) 148 ); 149 // clean all the root files 150 foreach ($filesToClean as $filePattern) { 151 foreach (glob($filePattern) as $filePath) { 152 unlink($filePath); 153 } 154 } 155 156 self::cleanConfigFiles(); 157 } 158 159 /** 160 * Clean config files 161 */ 162 static public function cleanConfigFiles() { 163 if (is_dir(self::CONF_DIR)) { 164 foreach(glob(self::CONF_DIR . '/*.conf') as $name) { 165 unlink($name); 166 } 167 rmdir(self::CONF_DIR); 168 } 169 } 170 171 /** 172 * @param int $backTraceIndex 173 * @return string 174 */ 175 static private function getCallerFileName($backTraceIndex = 1) 176 { 177 $backtrace = debug_backtrace(); 178 if (isset($backtrace[$backTraceIndex]['file'])) { 179 $filePath = $backtrace[$backTraceIndex]['file']; 180 } else { 181 $filePath = __FILE__; 182 } 183 184 return substr($filePath, 0, -strlen(pathinfo($filePath, PATHINFO_EXTENSION))); 185 } 186 187 /** 188 * @return bool|string 189 */ 190 static public function findExecutable() 191 { 192 $phpPath = getenv("TEST_PHP_EXECUTABLE"); 193 for ($i = 0; $i < 2; $i++) { 194 $slashPosition = strrpos($phpPath, "/"); 195 if ($slashPosition) { 196 $phpPath = substr($phpPath, 0, $slashPosition); 197 } else { 198 break; 199 } 200 } 201 202 if ($phpPath && is_dir($phpPath)) { 203 if (file_exists($phpPath."/fpm/php-fpm") && is_executable($phpPath."/fpm/php-fpm")) { 204 /* gotcha */ 205 return $phpPath."/fpm/php-fpm"; 206 } 207 $phpSbinFpmi = $phpPath."/sbin/php-fpm"; 208 if (file_exists($phpSbinFpmi) && is_executable($phpSbinFpmi)) { 209 return $phpSbinFpmi; 210 } 211 } 212 213 // try local php-fpm 214 $fpmPath = dirname(__DIR__) . '/php-fpm'; 215 if (file_exists($fpmPath) && is_executable($fpmPath)) { 216 return $fpmPath; 217 } 218 219 return false; 220 } 221 222 /** 223 * Skip test if any of the supplied files does not exist. 224 * 225 * @param mixed $files 226 */ 227 static public function skipIfAnyFileDoesNotExist($files) 228 { 229 if (!is_array($files)) { 230 $files = array($files); 231 } 232 foreach ($files as $file) { 233 if (!file_exists($file)) { 234 die("skip File $file does not exist"); 235 } 236 } 237 } 238 239 /** 240 * Skip test if config file is invalid. 241 * 242 * @param string $configTemplate 243 * @throws \Exception 244 */ 245 static public function skipIfConfigFails(string $configTemplate) 246 { 247 $tester = new self($configTemplate, '', [], self::getCallerFileName()); 248 $testResult = $tester->testConfig(); 249 if ($testResult !== null) { 250 self::clean(2); 251 die("skip $testResult"); 252 } 253 } 254 255 /** 256 * Skip test if IPv6 is not supported. 257 */ 258 static public function skipIfIPv6IsNotSupported() 259 { 260 @stream_socket_client('tcp://[::1]:0', $errno); 261 if ($errno != 111) { 262 die('skip IPv6 is not supported.'); 263 } 264 } 265 266 /** 267 * Skip if running on Travis. 268 * 269 * @param $message 270 */ 271 static public function skipIfTravis($message) 272 { 273 if (getenv("TRAVIS")) { 274 die('skip Travis: ' . $message); 275 } 276 } 277 278 /** 279 * Skip if not running as root. 280 */ 281 static public function skipIfNotRoot() 282 { 283 if (getmyuid() != 0) { 284 die('skip not running as root'); 285 } 286 } 287 288 /** 289 * Skip if running as root. 290 */ 291 static public function skipIfRoot() 292 { 293 if (getmyuid() == 0) { 294 die('skip running as root'); 295 } 296 } 297 298 /** 299 * Skip if posix extension not loaded. 300 */ 301 static public function skipIfPosixNotLoaded() 302 { 303 if (!extension_loaded('posix')) { 304 die('skip posix extension not loaded'); 305 } 306 } 307 308 /** 309 * Tester constructor. 310 * 311 * @param string|array $configTemplate 312 * @param string $code 313 * @param array $options 314 * @param string $fileName 315 */ 316 public function __construct( 317 $configTemplate, 318 string $code = '', 319 array $options = [], 320 $fileName = null 321 ) { 322 $this->configTemplate = $configTemplate; 323 $this->code = $code; 324 $this->options = $options; 325 $this->fileName = $fileName ?: self::getCallerFileName(); 326 $this->logTool = new LogTool(); 327 $this->debug = (bool) getenv('TEST_FPM_DEBUG'); 328 } 329 330 /** 331 * @param string $ini 332 */ 333 public function setUserIni(string $ini) 334 { 335 $iniFile = __DIR__ . '/.user.ini'; 336 file_put_contents($iniFile, $ini); 337 } 338 339 /** 340 * Test configuration file. 341 * 342 * @return null|string 343 * @throws \Exception 344 */ 345 public function testConfig() 346 { 347 $configFile = $this->createConfig(); 348 $cmd = self::findExecutable() . ' -t -y ' . $configFile . ' 2>&1'; 349 exec($cmd, $output, $code); 350 if ($code) { 351 return preg_replace("/\[.+?\]/", "", $output[0]); 352 } 353 354 return null; 355 } 356 357 /** 358 * Start PHP-FPM master process 359 * 360 * @param array $extraArgs 361 * @return bool 362 * @throws \Exception 363 */ 364 public function start(array $extraArgs = []) 365 { 366 $configFile = $this->createConfig(); 367 $desc = $this->outDesc ? [] : [1 => array('pipe', 'w'), 2 => array('redirect', 1)]; 368 $cmd = [self::findExecutable(), '-F', '-O', '-y', $configFile]; 369 if (getenv('TEST_FPM_RUN_AS_ROOT')) { 370 $cmd[] = '--allow-to-run-as-root'; 371 } 372 $cmd = array_merge($cmd, $extraArgs); 373 374 $this->masterProcess = proc_open($cmd, $desc, $pipes); 375 register_shutdown_function( 376 function($masterProcess) use($configFile) { 377 @unlink($configFile); 378 if (is_resource($masterProcess)) { 379 @proc_terminate($masterProcess); 380 while (proc_get_status($masterProcess)['running']) { 381 usleep(10000); 382 } 383 } 384 }, 385 $this->masterProcess 386 ); 387 if (!$this->outDesc !== false) { 388 $this->outDesc = $pipes[1]; 389 } 390 391 return true; 392 } 393 394 /** 395 * Run until needle is found in the log. 396 * 397 * @param string $needle 398 * @param int $max 399 * @return bool 400 * @throws \Exception 401 */ 402 public function runTill(string $needle, $max = 10) 403 { 404 $this->start(); 405 $found = false; 406 for ($i = 0; $i < $max; $i++) { 407 $line = $this->getLogLine(); 408 if (is_null($line)) { 409 break; 410 } 411 if (preg_match($needle, $line) === 1) { 412 $found = true; 413 break; 414 } 415 } 416 $this->close(true); 417 418 if (!$found) { 419 return $this->error("The search pattern not found"); 420 } 421 422 return true; 423 } 424 425 /** 426 * Check if connection works. 427 * 428 * @param string $host 429 * @param null|string $successMessage 430 * @param null|string $errorMessage 431 * @param int $attempts 432 * @param int $delay 433 */ 434 public function checkConnection( 435 $host = '127.0.0.1', 436 $successMessage = null, 437 $errorMessage = 'Connection failed', 438 $attempts = 20, 439 $delay = 50000 440 ) { 441 $i = 0; 442 do { 443 if ($i > 0 && $delay > 0) { 444 usleep($delay); 445 } 446 $fp = @fsockopen($host, $this->getPort()); 447 } while ((++$i < $attempts) && !$fp); 448 449 if ($fp) { 450 $this->message($successMessage); 451 fclose($fp); 452 } else { 453 $this->message($errorMessage); 454 } 455 } 456 457 458 /** 459 * Execute request with parameters ordered for better checking. 460 * 461 * @param string $address 462 * @param string|null $successMessage 463 * @param string|null $errorMessage 464 * @param string $uri 465 * @param string $query 466 * @param array $headers 467 * @return Response 468 */ 469 public function checkRequest( 470 string $address, 471 string $successMessage = null, 472 string $errorMessage = null, 473 $uri = '/ping', 474 $query = '', 475 $headers = [] 476 ) { 477 return $this->request($query, $headers, $uri, $address, $successMessage, $errorMessage); 478 } 479 480 /** 481 * Execute and check ping request. 482 * 483 * @param string $address 484 * @param string $pingPath 485 * @param string $pingResponse 486 */ 487 public function ping( 488 string $address = '{{ADDR}}', 489 string $pingResponse = 'pong', 490 string $pingPath = '/ping' 491 ) { 492 $response = $this->request('', [], $pingPath, $address); 493 $response->expectBody($pingResponse, 'text/plain'); 494 } 495 496 /** 497 * Execute and check status request(s). 498 * 499 * @param array $expectedFields 500 * @param string|null $address 501 * @param string $statusPath 502 * @param mixed $formats 503 * @throws \Exception 504 */ 505 public function status( 506 array $expectedFields, 507 string $address = null, 508 string $statusPath = '/status', 509 $formats = ['plain', 'html', 'xml', 'json'] 510 ) { 511 if (!is_array($formats)) { 512 $formats = [$formats]; 513 } 514 515 require_once "status.inc"; 516 $status = new Status(); 517 foreach ($formats as $format) { 518 $query = $format === 'plain' ? '' : $format; 519 $response = $this->request($query, [], $statusPath, $address); 520 $status->checkStatus($response, $expectedFields, $format); 521 } 522 } 523 524 /** 525 * Get request params array. 526 * 527 * @param string $query 528 * @param array $headers 529 * @param string|null $uri 530 * @param string|null $address 531 * @param string|null $successMessage 532 * @param string|null $errorMessage 533 * @param bool $connKeepAlive 534 * @return array 535 */ 536 private function getRequestParams( 537 string $query = '', 538 array $headers = [], 539 string $uri = null 540 ) { 541 if (is_null($uri)) { 542 $uri = $this->makeSourceFile(); 543 } 544 545 $params = array_merge( 546 [ 547 'GATEWAY_INTERFACE' => 'FastCGI/1.0', 548 'REQUEST_METHOD' => 'GET', 549 'SCRIPT_FILENAME' => $uri, 550 'SCRIPT_NAME' => $uri, 551 'QUERY_STRING' => $query, 552 'REQUEST_URI' => $uri . ($query ? '?'.$query : ""), 553 'DOCUMENT_URI' => $uri, 554 'SERVER_SOFTWARE' => 'php/fcgiclient', 555 'REMOTE_ADDR' => '127.0.0.1', 556 'REMOTE_PORT' => '7777', 557 'SERVER_ADDR' => '127.0.0.1', 558 'SERVER_PORT' => '80', 559 'SERVER_NAME' => php_uname('n'), 560 'SERVER_PROTOCOL' => 'HTTP/1.1', 561 'DOCUMENT_ROOT' => __DIR__, 562 'CONTENT_TYPE' => '', 563 'CONTENT_LENGTH' => 0 564 ], 565 $headers 566 ); 567 568 return array_filter($params, function($value) { 569 return !is_null($value); 570 }); 571 } 572 573 /** 574 * Execute request. 575 * 576 * @param string $query 577 * @param array $headers 578 * @param string|null $uri 579 * @param string|null $address 580 * @param string|null $successMessage 581 * @param string|null $errorMessage 582 * @param bool $connKeepAlive 583 * @return Response 584 */ 585 public function request( 586 string $query = '', 587 array $headers = [], 588 string $uri = null, 589 string $address = null, 590 string $successMessage = null, 591 string $errorMessage = null, 592 bool $connKeepAlive = false 593 ) { 594 if ($this->hasError()) { 595 return new Response(null, true); 596 } 597 598 $params = $this->getRequestParams($query, $headers, $uri); 599 600 try { 601 $this->response = new Response( 602 $this->getClient($address, $connKeepAlive)->request_data($params, false) 603 ); 604 $this->message($successMessage); 605 } catch (\Exception $exception) { 606 if ($errorMessage === null) { 607 $this->error("Request failed", $exception); 608 } else { 609 $this->message($errorMessage); 610 } 611 $this->response = new Response(); 612 } 613 if ($this->debug) { 614 $this->response->debugOutput(); 615 } 616 return $this->response; 617 } 618 619 /** 620 * Execute multiple requests in parallel. 621 * 622 * @param array|int $requests 623 * @param string|null $address 624 * @param string|null $successMessage 625 * @param string|null $errorMessage 626 * @param bool $connKeepAlive 627 * @return Response[] 628 * @throws \Exception 629 */ 630 public function multiRequest( 631 $requests, 632 string $address = null, 633 string $successMessage = null, 634 string $errorMessage = null, 635 bool $connKeepAlive = false 636 ) { 637 if ($this->hasError()) { 638 return new Response(null, true); 639 } 640 641 if (is_numeric($requests)) { 642 $requests = array_fill(0, $requests, []); 643 } elseif (!is_array($requests)) { 644 throw new \Exception('Requests can be either numeric or array'); 645 } 646 647 try { 648 $connections = array_map(function ($requestData) use ($address, $connKeepAlive) { 649 $client = $this->getClient($address, $connKeepAlive); 650 $params = $this->getRequestParams( 651 $requestData['query'] ?? '', 652 $requestData['headers'] ?? [], 653 $requestData['uri'] ?? null 654 ); 655 return [ 656 'client' => $client, 657 'requestId' => $client->async_request($params, false), 658 ]; 659 }, $requests); 660 661 $responses = array_map(function ($conn) { 662 $response = new Response($conn['client']->wait_for_response_data($conn['requestId'])); 663 if ($this->debug) { 664 $response->debugOutput(); 665 } 666 return $response; 667 }, $connections); 668 $this->message($successMessage); 669 return $responses; 670 } catch (\Exception $exception) { 671 if ($errorMessage === null) { 672 $this->error("Request failed", $exception); 673 } else { 674 $this->message($errorMessage); 675 } 676 } 677 } 678 679 /** 680 * Get client. 681 * 682 * @param string $address 683 * @param bool $keepAlive 684 * @return Client 685 */ 686 private function getClient(string $address = null, $keepAlive = false) 687 { 688 $address = $address ? $this->processTemplate($address) : $this->getAddr(); 689 if ($address[0] === '/') { // uds 690 $host = 'unix://' . $address; 691 $port = -1; 692 } elseif ($address[0] === '[') { // ipv6 693 $addressParts = explode(']:', $address); 694 $host = $addressParts[0]; 695 if (isset($addressParts[1])) { 696 $host .= ']'; 697 $port = $addressParts[1]; 698 } else { 699 $port = $this->getPort(); 700 } 701 } else { // ipv4 702 $addressParts = explode(':', $address); 703 $host = $addressParts[0]; 704 $port = $addressParts[1] ?? $this->getPort(); 705 } 706 707 if (!$keepAlive) { 708 return new Client($host, $port); 709 } 710 711 if (!isset($this->clients[$host][$port])) { 712 $client = new Client($host, $port); 713 $client->setKeepAlive(true); 714 $this->clients[$host][$port] = $client; 715 } 716 717 return $this->clients[$host][$port]; 718 } 719 720 /** 721 * Display logs 722 * 723 * @param int $number 724 * @param string $ignore 725 */ 726 public function displayLog(int $number = 1, string $ignore = 'systemd') 727 { 728 /* Read $number lines or until EOF */ 729 while ($number > 0 || ($number < 0 && !feof($this->outDesc))) { 730 $a = fgets($this->outDesc); 731 if (empty($ignore) || !strpos($a, $ignore)) { 732 echo $a; 733 $number--; 734 } 735 } 736 } 737 738 /** 739 * Get a single log line 740 * 741 * @return null|string 742 */ 743 private function getLogLine() 744 { 745 $read = [$this->outDesc]; 746 $write = null; 747 $except = null; 748 if (stream_select($read, $write, $except, $timeout=3)) { 749 return fgets($this->outDesc); 750 } else { 751 return null; 752 } 753 } 754 755 /** 756 * Get log lines 757 * 758 * @param int $number 759 * @param bool $skipBlank 760 * @param string $ignore 761 * @return array 762 */ 763 public function getLogLines(int $number = 1, bool $skipBlank = false, string $ignore = 'systemd') 764 { 765 $lines = []; 766 /* Read $n lines or until EOF */ 767 while ($number > 0 || ($number < 0 && !feof($this->outDesc))) { 768 $line = $this->getLogLine(); 769 if (is_null($line)) { 770 break; 771 } 772 if ((empty($ignore) || !strpos($line, $ignore)) && (!$skipBlank || strlen(trim($line)) > 0)) { 773 $lines[] = $line; 774 $number--; 775 } 776 } 777 778 if ($this->debug) { 779 foreach ($lines as $line) { 780 echo "LOG LINE: " . $line; 781 } 782 } 783 784 return $lines; 785 } 786 787 /** 788 * @return mixed|string 789 */ 790 public function getLastLogLine() 791 { 792 $lines = $this->getLogLines(); 793 794 return $lines[0] ?? ''; 795 } 796 797 /** 798 * @return string 799 */ 800 public function getUser() 801 { 802 return get_current_user(); 803 } 804 805 /** 806 * @return string 807 */ 808 public function getGroup() 809 { 810 return get_current_group(); 811 } 812 813 /** 814 * @return int 815 */ 816 public function getUid() 817 { 818 return getmyuid(); 819 } 820 821 /** 822 * @return int 823 */ 824 public function getGid() 825 { 826 return getmygid(); 827 } 828 829 /** 830 * Reload FPM by sending USR2 signal and optionally change config before that. 831 * 832 * @param string|array $configTemplate 833 * @return string 834 * @throws \Exception 835 */ 836 public function reload($configTemplate = null) 837 { 838 if (!is_null($configTemplate)) { 839 self::cleanConfigFiles(); 840 $this->configTemplate = $configTemplate; 841 $this->createConfig(); 842 } 843 844 return $this->signal('USR2'); 845 } 846 847 /** 848 * Send signal to the supplied PID or the server PID. 849 * 850 * @param string $signal 851 * @param int|null $pid 852 * @return string 853 */ 854 public function signal($signal, int $pid = null) 855 { 856 if (is_null($pid)) { 857 $pid = $this->getPid(); 858 } 859 860 return exec("kill -$signal $pid"); 861 } 862 863 /** 864 * Terminate master process 865 */ 866 public function terminate() 867 { 868 proc_terminate($this->masterProcess); 869 } 870 871 /** 872 * Close all open descriptors and process resources 873 * 874 * @param bool $terminate 875 */ 876 public function close($terminate = false) 877 { 878 if ($terminate) { 879 $this->terminate(); 880 } 881 fclose($this->outDesc); 882 proc_close($this->masterProcess); 883 } 884 885 /** 886 * Create a config file. 887 * 888 * @param string $extension 889 * @return string 890 * @throws \Exception 891 */ 892 private function createConfig($extension = 'ini') 893 { 894 if (is_array($this->configTemplate)) { 895 $configTemplates = $this->configTemplate; 896 if (!isset($configTemplates['main'])) { 897 throw new \Exception('The config template array has to have main config'); 898 } 899 $mainTemplate = $configTemplates['main']; 900 if (!is_dir(self::CONF_DIR)) { 901 mkdir(self::CONF_DIR); 902 } 903 foreach ($this->createPoolConfigs($configTemplates) as $name => $poolConfig) { 904 $this->makeFile( 905 'conf', 906 $this->processTemplate($poolConfig), 907 self::CONF_DIR, 908 $name 909 ); 910 } 911 } else { 912 $mainTemplate = $this->configTemplate; 913 } 914 915 return $this->makeFile($extension, $this->processTemplate($mainTemplate)); 916 } 917 918 /** 919 * Create pool config templates. 920 * 921 * @param array $configTemplates 922 * @return array 923 * @throws \Exception 924 */ 925 private function createPoolConfigs(array $configTemplates) 926 { 927 if (!isset($configTemplates['poolTemplate'])) { 928 unset($configTemplates['main']); 929 return $configTemplates; 930 } 931 $poolTemplate = $configTemplates['poolTemplate']; 932 $configs = []; 933 if (isset($configTemplates['count'])) { 934 $start = $configTemplates['start'] ?? 1; 935 for ($i = $start; $i < $start + $configTemplates['count']; $i++) { 936 $configs[$i] = str_replace('%index%', $i, $poolTemplate); 937 } 938 } elseif (isset($configTemplates['names'])) { 939 foreach($configTemplates['names'] as $name) { 940 $configs[$name] = str_replace('%name%', $name, $poolTemplate); 941 } 942 } else { 943 throw new \Exception('The config template requires count or names if poolTemplate set'); 944 } 945 return $configs; 946 } 947 948 /** 949 * Process template string. 950 * 951 * @param string $template 952 * @return string 953 */ 954 private function processTemplate(string $template) 955 { 956 $vars = [ 957 'FILE:LOG:ACC' => ['getAbsoluteFile', self::FILE_EXT_LOG_ACC], 958 'FILE:LOG:ERR' => ['getAbsoluteFile', self::FILE_EXT_LOG_ERR], 959 'FILE:LOG:SLOW' => ['getAbsoluteFile', self::FILE_EXT_LOG_SLOW], 960 'FILE:PID' => ['getAbsoluteFile', self::FILE_EXT_PID], 961 'RFILE:LOG:ACC' => ['getRelativeFile', self::FILE_EXT_LOG_ACC], 962 'RFILE:LOG:ERR' => ['getRelativeFile', self::FILE_EXT_LOG_ERR], 963 'RFILE:LOG:SLOW' => ['getRelativeFile', self::FILE_EXT_LOG_SLOW], 964 'RFILE:PID' => ['getRelativeFile', self::FILE_EXT_PID], 965 'ADDR:IPv4' => ['getAddr', 'ipv4'], 966 'ADDR:IPv4:ANY' => ['getAddr', 'ipv4-any'], 967 'ADDR:IPv6' => ['getAddr', 'ipv6'], 968 'ADDR:IPv6:ANY' => ['getAddr', 'ipv6-any'], 969 'ADDR:UDS' => ['getAddr', 'uds'], 970 'PORT' => ['getPort', 'ip'], 971 'INCLUDE:CONF' => self::CONF_DIR . '/*.conf', 972 'USER' => ['getUser'], 973 'GROUP' => ['getGroup'], 974 'UID' => ['getUid'], 975 'GID' => ['getGid'], 976 ]; 977 $aliases = [ 978 'ADDR' => 'ADDR:IPv4', 979 'FILE:LOG' => 'FILE:LOG:ERR', 980 ]; 981 foreach ($aliases as $aliasName => $aliasValue) { 982 $vars[$aliasName] = $vars[$aliasValue]; 983 } 984 985 return preg_replace_callback( 986 '/{{([a-zA-Z0-9:]+)(\[\w+\])?}}/', 987 function ($matches) use ($vars) { 988 $varName = $matches[1]; 989 if (!isset($vars[$varName])) { 990 $this->error("Invalid config variable $varName"); 991 return 'INVALID'; 992 } 993 $pool = $matches[2] ?? 'default'; 994 $varValue = $vars[$varName]; 995 if (is_string($varValue)) { 996 return $varValue; 997 } 998 $functionName = array_shift($varValue); 999 $varValue[] = $pool; 1000 return call_user_func_array([$this, $functionName], $varValue); 1001 }, 1002 $template 1003 ); 1004 } 1005 1006 /** 1007 * @param string $type 1008 * @param string $pool 1009 * @return string 1010 */ 1011 public function getAddr(string $type = 'ipv4', $pool = 'default') 1012 { 1013 $port = $this->getPort($type, $pool, true); 1014 if ($type === 'uds') { 1015 $address = $this->getFile($port . '.sock'); 1016 1017 // Socket max path length is 108 on Linux and 104 on BSD, 1018 // so we use the latter 1019 if (strlen($address) <= 104) { 1020 return $address; 1021 } 1022 1023 return sys_get_temp_dir().'/'. 1024 hash('crc32', dirname($address)).'-'. 1025 basename($address); 1026 } 1027 1028 return $this->getHost($type) . ':' . $port; 1029 } 1030 1031 /** 1032 * @param string $type 1033 * @param string $pool 1034 * @param bool $useAsId 1035 * @return int 1036 */ 1037 public function getPort(string $type = 'ip', $pool = 'default', $useAsId = false) 1038 { 1039 if ($type === 'uds' && !$useAsId) { 1040 return -1; 1041 } 1042 1043 if (isset($this->ports['values'][$pool])) { 1044 return $this->ports['values'][$pool]; 1045 } 1046 $port = ($this->ports['last'] ?? 9000 + PHP_INT_SIZE - 1) + 1; 1047 $this->ports['values'][$pool] = $this->ports['last'] = $port; 1048 1049 return $port; 1050 } 1051 1052 /** 1053 * @param string $type 1054 * @return string 1055 */ 1056 public function getHost(string $type = 'ipv4') 1057 { 1058 switch ($type) { 1059 case 'ipv6-any': 1060 return '[::]'; 1061 case 'ipv6': 1062 return '[::1]'; 1063 case 'ipv4-any': 1064 return '0.0.0.0'; 1065 default: 1066 return '127.0.0.1'; 1067 } 1068 } 1069 1070 /** 1071 * Get listen address. 1072 * 1073 * @param string|null $template 1074 * @return string 1075 */ 1076 public function getListen($template = null) 1077 { 1078 return $template ? $this->processTemplate($template) : $this->getAddr(); 1079 } 1080 1081 /** 1082 * Get PID. 1083 * 1084 * @return int 1085 */ 1086 public function getPid() 1087 { 1088 $pidFile = $this->getFile('pid'); 1089 if (!is_file($pidFile)) { 1090 return (int) $this->error("PID file has not been created"); 1091 } 1092 $pidContent = file_get_contents($pidFile); 1093 if (!is_numeric($pidContent)) { 1094 return (int) $this->error("PID content '$pidContent' is not integer"); 1095 } 1096 1097 return (int) $pidContent; 1098 } 1099 1100 1101 /** 1102 * @param string $extension 1103 * @param string|null $dir 1104 * @param string|null $name 1105 * @return string 1106 */ 1107 private function getFile(string $extension, $dir = null, $name = null) 1108 { 1109 $fileName = (is_null($name) ? $this->fileName : $name . '.') . $extension; 1110 1111 return is_null($dir) ? $fileName : $dir . '/' . $fileName; 1112 } 1113 1114 /** 1115 * @param string $extension 1116 * @return string 1117 */ 1118 private function getAbsoluteFile(string $extension) 1119 { 1120 return $this->getFile($extension); 1121 } 1122 1123 /** 1124 * @param string $extension 1125 * @return string 1126 */ 1127 private function getRelativeFile(string $extension) 1128 { 1129 $fileName = rtrim(basename($this->fileName), '.'); 1130 1131 return $this->getFile($extension, null, $fileName); 1132 } 1133 1134 /** 1135 * @param string $extension 1136 * @param string $prefix 1137 * @return string 1138 */ 1139 private function getPrefixedFile(string $extension, string $prefix = null) 1140 { 1141 $fileName = rtrim($this->fileName, '.'); 1142 if (!is_null($prefix)) { 1143 $fileName = $prefix . '/' . basename($fileName); 1144 } 1145 1146 return $this->getFile($extension, null, $fileName); 1147 } 1148 1149 /** 1150 * @param string $extension 1151 * @param string $content 1152 * @param string|null $dir 1153 * @param string|null $name 1154 * @return string 1155 */ 1156 private function makeFile(string $extension, string $content = '', $dir = null, $name = null) 1157 { 1158 $filePath = $this->getFile($extension, $dir, $name); 1159 file_put_contents($filePath, $content); 1160 1161 return $filePath; 1162 } 1163 1164 /** 1165 * @return string 1166 */ 1167 public function makeSourceFile() 1168 { 1169 return $this->makeFile('src.php', $this->code); 1170 } 1171 1172 /** 1173 * @param string|null $msg 1174 */ 1175 private function message($msg) 1176 { 1177 if ($msg !== null) { 1178 echo "$msg\n"; 1179 } 1180 } 1181 1182 /** 1183 * @param string $msg 1184 * @param \Exception|null $exception 1185 */ 1186 private function error($msg, \Exception $exception = null) 1187 { 1188 $this->error = 'ERROR: ' . $msg; 1189 if ($exception) { 1190 $this->error .= '; EXCEPTION: ' . $exception->getMessage(); 1191 } 1192 $this->error .= "\n"; 1193 1194 echo $this->error; 1195 } 1196 1197 /** 1198 * @return bool 1199 */ 1200 private function hasError() 1201 { 1202 return !is_null($this->error) || !is_null($this->logTool->getError()); 1203 } 1204 1205 /** 1206 * Expect file with a supplied extension to exist. 1207 * 1208 * @param string $extension 1209 * @param string $prefix 1210 * @return bool 1211 */ 1212 public function expectFile(string $extension, $prefix = null) 1213 { 1214 $filePath = $this->getPrefixedFile($extension, $prefix); 1215 if (!file_exists($filePath)) { 1216 return $this->error("The file $filePath does not exist"); 1217 } 1218 1219 return true; 1220 } 1221 1222 /** 1223 * Expect file with a supplied extension to not exist. 1224 * 1225 * @param string $extension 1226 * @param string $prefix 1227 * @return bool 1228 */ 1229 public function expectNoFile(string $extension, $prefix = null) 1230 { 1231 $filePath = $this->getPrefixedFile($extension, $prefix); 1232 if (file_exists($filePath)) { 1233 return $this->error("The file $filePath exists"); 1234 } 1235 1236 return true; 1237 } 1238 1239 /** 1240 * Expect message to be written to FastCGI error stream. 1241 * 1242 * @param string $message 1243 * @param int $limit 1244 * @param int $repeat 1245 */ 1246 public function expectFastCGIErrorMessage( 1247 string $message, 1248 int $limit = 1024, 1249 int $repeat = 0 1250 ) { 1251 $this->logTool->setExpectedMessage($message, $limit, $repeat); 1252 $this->logTool->checkTruncatedMessage($this->response->getErrorData()); 1253 } 1254 1255 /** 1256 * Expect reloading lines to be logged. 1257 * 1258 * @param int $socketCount 1259 */ 1260 public function expectLogReloadingNotices($socketCount = 1) 1261 { 1262 $this->logTool->expectReloadingLines($this->getLogLines($socketCount + 4)); 1263 } 1264 1265 /** 1266 * Expect starting lines to be logged. 1267 */ 1268 public function expectLogStartNotices() 1269 { 1270 $this->logTool->expectStartingLines($this->getLogLines(2)); 1271 } 1272 1273 /** 1274 * Expect terminating lines to be logged. 1275 */ 1276 public function expectLogTerminatingNotices() 1277 { 1278 $this->logTool->expectTerminatorLines($this->getLogLines(-1)); 1279 } 1280 1281 /** 1282 * Expect log message that can span multiple lines. 1283 * 1284 * @param string $message 1285 * @param int $limit 1286 * @param int $repeat 1287 * @param bool $decorated 1288 * @param bool $wrapped 1289 */ 1290 public function expectLogMessage( 1291 string $message, 1292 int $limit = 1024, 1293 int $repeat = 0, 1294 bool $decorated = true, 1295 bool $wrapped = true 1296 ) { 1297 $this->logTool->setExpectedMessage($message, $limit, $repeat); 1298 if ($wrapped) { 1299 $logLines = $this->getLogLines(-1, true); 1300 $this->logTool->checkWrappedMessage($logLines, true, $decorated); 1301 } else { 1302 $logLines = $this->getLogLines(1, true); 1303 $this->logTool->checkTruncatedMessage($logLines[0] ?? ''); 1304 } 1305 if ($this->debug) { 1306 $this->message("-------------- LOG LINES: -------------"); 1307 var_dump($logLines); 1308 $this->message("---------------------------------------\n"); 1309 } 1310 } 1311 1312 /** 1313 * Expect a single log line. 1314 * 1315 * @param string $message 1316 * @return bool 1317 */ 1318 public function expectLogLine(string $message, bool $is_stderr = true) 1319 { 1320 $messageLen = strlen($message); 1321 $limit = $messageLen > 1024 ? $messageLen + 16 : 1024; 1322 $this->logTool->setExpectedMessage($message, $limit); 1323 $logLines = $this->getLogLines(1, true); 1324 if ($this->debug) { 1325 $this->message("LOG LINE: " . ($logLines[0] ?? '')); 1326 } 1327 1328 return $this->logTool->checkWrappedMessage($logLines, false, true, $is_stderr); 1329 } 1330 1331 /** 1332 * Expect log entry. 1333 * 1334 * @param string $type The log type 1335 * @param string $message The expected message 1336 * @param string|null $pool The pool for pool prefixed log entry 1337 * @param int $count The number of items 1338 * @return bool 1339 */ 1340 private function expectLogEntry(string $type, string $message, $pool = null, $count = 1) 1341 { 1342 for ($i = 0; $i < $count; $i++) { 1343 if (!$this->logTool->expectEntry($type, $this->getLastLogLine(), $message, $pool)) { 1344 return false; 1345 } 1346 } 1347 return true; 1348 } 1349 1350 /** 1351 * Expect a log debug message. 1352 * 1353 * @param string $message 1354 * @param string|null $pool 1355 * @param int $count 1356 * @return bool 1357 */ 1358 public function expectLogDebug(string $message, $pool = null, $count = 1) 1359 { 1360 return $this->expectLogEntry(LogTool::DEBUG, $message, $pool, $count); 1361 } 1362 1363 /** 1364 * Expect a log notice. 1365 * 1366 * @param string $message 1367 * @param string|null $pool 1368 * @param int $count 1369 * @return bool 1370 */ 1371 public function expectLogNotice(string $message, $pool = null, $count = 1) 1372 { 1373 return $this->expectLogEntry(LogTool::NOTICE, $message, $pool, $count); 1374 } 1375 1376 /** 1377 * Expect a log warning. 1378 * 1379 * @param string $message 1380 * @param string|null $pool 1381 * @param int $count 1382 * @return bool 1383 */ 1384 public function expectLogWarning(string $message, $pool = null, $count = 1) 1385 { 1386 return $this->expectLogEntry(LogTool::WARNING, $message, $pool, $count); 1387 } 1388 1389 /** 1390 * Expect a log error. 1391 * 1392 * @param string $message 1393 * @param string|null $pool 1394 * @param int $count 1395 * @return bool 1396 */ 1397 public function expectLogError(string $message, $pool = null, $count = 1) 1398 { 1399 return $this->expectLogEntry(LogTool::ERROR, $message, $pool, $count); 1400 } 1401 1402 /** 1403 * Expect a log alert. 1404 * 1405 * @param string $message 1406 * @param string|null $pool 1407 * @param int $count 1408 * @return bool 1409 */ 1410 public function expectLogAlert(string $message, $pool = null, $count = 1) 1411 { 1412 return $this->expectLogEntry(LogTool::ALERT, $message, $pool, $count); 1413 } 1414 1415 /** 1416 * Expect no log lines to be logged. 1417 * 1418 * @return bool 1419 */ 1420 public function expectNoLogMessages() 1421 { 1422 $logLines = $this->getLogLines(-1, true); 1423 if (!empty($logLines)) { 1424 return $this->error( 1425 "Expected no log lines but following lines logged:\n" . implode("\n", $logLines) 1426 ); 1427 } 1428 1429 return true; 1430 } 1431 1432 /** 1433 * Print content of access log. 1434 */ 1435 public function printAccessLog() 1436 { 1437 $accessLog = $this->getFile('acc.log'); 1438 if (is_file($accessLog)) { 1439 print file_get_contents($accessLog); 1440 } 1441 } 1442} 1443