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