1<?php 2 3namespace FPM; 4 5class LogTool 6{ 7 const P_TIME = '\[\d\d-\w\w\w-\d{4} \d\d:\d\d:\d\d(?:\.\d+)?\]'; 8 const P_DEBUG = '(?:pid \d+, (?:\w+|\(null\))\(\), line \d+: )?'; 9 const P_PREFIX = '\[pool unconfined\] child \d+ said into stderr: '; 10 const P_PREFIX_STDOUT = '\[pool unconfined\] child \d+ said into stdout: '; 11 const FINAL_SUFFIX = ', pipe is closed'; 12 13 const DEBUG = 'DEBUG'; 14 const NOTICE = 'NOTICE'; 15 const WARNING = 'WARNING'; 16 const ERROR = 'ERROR'; 17 const ALERT = 'ALERT'; 18 19 /** 20 * @var bool 21 */ 22 private bool $debug; 23 24 /** 25 * @var LogReader 26 */ 27 private LogReader $logReader; 28 29 /** 30 * @var string 31 */ 32 private string $message; 33 34 /** 35 * @var string|null 36 */ 37 private ?string $level = null; 38 39 /** 40 * @var int 41 */ 42 private int $position; 43 44 /** 45 * @var int 46 */ 47 private int $suffixPosition = 0; 48 49 /** 50 * @var int 51 */ 52 private int $limit; 53 54 /** 55 * @var string|null 56 */ 57 private ?string $pattern; 58 59 /** 60 * @var string|null 61 */ 62 private ?string $error = null; 63 64 /** 65 * @var bool 66 */ 67 private bool $pipeClosed = false; 68 69 /** 70 * Log tool constructor. 71 * 72 * @param LogReader $logReader 73 * @param bool $debug 74 */ 75 public function __construct(LogReader $logReader, bool $debug = false) 76 { 77 $this->logReader = $logReader; 78 $this->debug = $debug; 79 } 80 81 /** 82 * Set expected message for output logging. 83 * 84 * @param string $message 85 * @param int $limit 86 * @param int $repeat 87 */ 88 public function setExpectedMessage(string $message, int $limit, int $repeat = 0) 89 { 90 $this->message = ($repeat > 0) ? str_repeat($message, $repeat) : $message; 91 $this->limit = $limit; 92 $this->position = 0; 93 } 94 95 /** 96 * Set the expected logging level. 97 * 98 * @param string $level 99 * 100 * @return string 101 */ 102 public function setExpectedLevel(string $level): string 103 { 104 return $this->level = $level; 105 } 106 107 /** 108 * Get the expected logging level. 109 * 110 * @return string 111 */ 112 public function getExpectedLevel(): string 113 { 114 return $this->level ?: 'WARNING'; 115 } 116 117 /** 118 * Set whether pipe closed error is shown. 119 * 120 * @param bool $pipeClosed 121 */ 122 public function setPipeClosed(bool $pipeClosed): void 123 { 124 $this->pipeClosed = $pipeClosed; 125 } 126 127 /** 128 * Match the matcher checking the log lines using the callback. 129 * 130 * @param callable $matcher Callback checking whether the log line matches the expected message. 131 * @param string|null $notFoundMessage Error message to show if the message is not found. 132 * @param bool $checkAllLogs Whether to also check past logs. 133 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 134 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 135 * 136 * @return bool 137 * @throws \Exception 138 */ 139 private function match( 140 callable $matcher, 141 string $notFoundMessage = null, 142 bool $checkAllLogs = false, 143 int $timeoutSeconds = null, 144 int $timeoutMicroseconds = null 145 ): bool { 146 if ($this->getError()) { 147 return false; 148 } 149 150 if ($this->logReader->readUntil( 151 $matcher, 152 $notFoundMessage, 153 $checkAllLogs, 154 $timeoutSeconds, 155 $timeoutMicroseconds 156 )) { 157 $this->popError(); 158 159 return true; 160 } 161 echo $this->popError(); 162 163 return false; 164 } 165 166 /** 167 * @param string|null $line Log line to check against. 168 * 169 * @return bool 170 * @throws \Exception 171 */ 172 public function checkTruncatedMessage(string $line = null): bool 173 { 174 if ($this->message === null) { 175 throw new \LogicException('The message has not been set'); 176 } 177 178 $matcher = function (string $line) { 179 $lineLen = strlen($line); 180 if ( ! $this->checkLineLength($line)) { 181 return false; 182 } 183 $this->pattern = '/^PHP message: (.*?)(\.\.\.)?$/'; 184 if (preg_match($this->pattern, $line, $matches) === 0) { 185 return $this->error("Unexpected truncated message: {$line}"); 186 } 187 188 if ($lineLen === $this->limit - strlen('NOTICE: ') - 1) { 189 if ( ! isset($matches[2])) { 190 return $this->error("The truncated line is not ended with '...'"); 191 } 192 if ( ! $this->checkMessage($matches[1])) { 193 return false; 194 } 195 } else { 196 if (isset($matches[2])) { 197 // this is expecting that the expected message does not end with '...' 198 // which should not be an issue for the test purpose. 199 return $this->error("The line is complete and should not end with '...'"); 200 } 201 if ( ! $this->checkMessage($matches[1], -1)) { 202 return false; 203 } 204 } 205 206 return true; 207 }; 208 209 if ($line !== null) { 210 return $matcher($line); 211 } else { 212 return $this->match($matcher, 'Truncated message not found'); 213 } 214 } 215 216 /** 217 * Check wrapped message. 218 * 219 * @param bool $terminated Whether to check termination lines. 220 * @param bool $decorated Whether the output is decorated with prefix and suffix. 221 * @param bool $isStdErr Whether the message is written to stderr. 222 * 223 * @return bool 224 * @throws \Exception 225 */ 226 public function checkWrappedMessage( 227 bool $terminated = true, 228 bool $decorated = true, 229 bool $isStdErr = true, 230 ): bool { 231 if ($this->message === null) { 232 throw new \LogicException('The message has not been set'); 233 } 234 if ($decorated) { 235 $this->pattern = sprintf( 236 '/^(%s %s: %s%s)"([^"]*)"(.*)?$/', 237 self::P_TIME, 238 $this->getExpectedLevel(), 239 self::P_DEBUG, 240 $isStdErr ? self::P_PREFIX : self::P_PREFIX_STDOUT 241 ); 242 } else { 243 $this->pattern = null; 244 } 245 246 $matcher = fn(string $line) => $this->checkLine($line); 247 248 while (strlen($this->message) !== $this->position) { 249 if ( ! $this->match($matcher, 'Output message not found')) { 250 return false; 251 } 252 } 253 254 if ($this->suffixPosition > 0) { 255 $suffixMatcher = function ($line) use ($isStdErr) { 256 $suffixPattern = sprintf( 257 '/^%s %s: %s%s(.*)$/', 258 self::P_TIME, 259 $this->getExpectedLevel(), 260 self::P_DEBUG, 261 $isStdErr ? self::P_PREFIX : self::P_PREFIX_STDOUT 262 ); 263 if (preg_match($suffixPattern, $line, $matches) === 0) { 264 return $this->error("Unexpected line: $line"); 265 } 266 if ($matches[1] !== substr(self::FINAL_SUFFIX, $this->suffixPosition)) { 267 return $this->error( 268 "The suffix has not been finished from position $this->suffixPosition in line: $line" 269 ); 270 } 271 272 return true; 273 }; 274 if ( ! $this->match($suffixMatcher, 'Suffix message not found')) { 275 return false; 276 } 277 $this->suffixPosition = 0; 278 } 279 280 if ($terminated) { 281 return $this->expectTerminatorLines(); 282 } 283 284 return true; 285 } 286 287 /** 288 * Check workers output line. 289 * 290 * @param string $line Log output line. 291 * 292 * @return bool 293 */ 294 private function checkLine(string $line): bool 295 { 296 $useLine = $this->pattern === null; 297 if ($useLine) { 298 // plain (not decorated) output 299 $out = rtrim($line); 300 $finalSuffix = null; 301 } elseif (preg_match($this->pattern, $line, $matches) > 0) { 302 $out = $matches[2]; 303 $finalSuffix = $matches[3] ?? false; 304 } else { 305 return $this->error("Unexpected line: $line", $line); 306 } 307 308 $rem = strlen($this->message) - $this->position; 309 $lineLen = strlen($line); 310 if ( ! $this->checkLineLength($line, $lineLen)) { 311 return false; 312 } 313 if ( ! $this->checkMessage($out, $this->position, $useLine)) { 314 return false; 315 } 316 $outLen = strlen($out); 317 $this->position += $outLen; 318 if ($rem > $outLen) { // continuous line 319 if ($lineLen !== $this->limit) { 320 if ($lineLen + ($rem - $outLen) < $this->limit) { 321 return $this->error("Printed less than the message len"); 322 } 323 324 return $this->error( 325 "The continuous line length is $lineLen but it should equal to limit $this->limit" 326 ); 327 } 328 329 return true; 330 } 331 if ($rem !== $outLen) { 332 return $this->error("Printed more than the message len"); 333 } 334 if ( ! $this->pipeClosed || $finalSuffix === null) { 335 return true; 336 } 337 if ($finalSuffix === false) { 338 return $this->error("No final suffix"); 339 } 340 if (empty($finalSuffix) || ! str_contains(self::FINAL_SUFFIX, $finalSuffix)) { 341 return $this->error("The final suffix has to be equal to ', pipe is closed'"); 342 } 343 if (self::FINAL_SUFFIX !== $finalSuffix) { 344 $this->suffixPosition = strlen($finalSuffix); 345 } 346 347 return true; 348 } 349 350 /** 351 * Check the message line length - specifically if it's behind the limit. 352 * 353 * @param string $line Log output line. 354 * @param int|null $lineLen Line length. 355 * 356 * @return bool 357 */ 358 private function checkLineLength(string $line, int $lineLen = null): bool 359 { 360 $lineLen = $lineLen ?: strlen($line); 361 if ($lineLen > $this->limit) { 362 return $this->error( 363 "The line length is $lineLen which is higher than limit $this->limit", 364 $line 365 ); 366 } 367 368 return true; 369 } 370 371 /** 372 * Check whether matched message part matches the expected message. 373 * 374 * @param string $matchedMessage The output message or part of it (match). 375 * @param int $expectedMessageStart Message position. 376 * @param bool $isLine Whether the whole log line is provided as a matched message. 377 * 378 * @return bool 379 */ 380 private function checkMessage( 381 string $matchedMessage, 382 int $expectedMessageStart = 0, 383 bool $isLine = false 384 ): bool { 385 if ($expectedMessageStart < 0) { 386 $expectedMessage = $this->message; 387 } else { 388 $expectedMessage = substr($this->message, $expectedMessageStart, strlen($matchedMessage)); 389 } 390 if ($expectedMessage !== $matchedMessage) { 391 return $this->error( 392 $this->getMatchDebugMessage( 393 sprintf( 394 "The actual string(%d) does not match expected string(%d):\n", 395 strlen($matchedMessage), 396 strlen($expectedMessage) 397 ), 398 expectedMessage: $expectedMessage, 399 actualMessage: "'$matchedMessage'", 400 ), 401 $isLine ? $matchedMessage : null 402 ); 403 } 404 405 $this->traceMatch( 406 "Message matched", 407 expectedMessage: $expectedMessage, 408 actualMessage: "'$matchedMessage'", 409 ); 410 411 return true; 412 } 413 414 /** 415 * Expect log entries for daemon reloading. 416 * 417 * @param int $expectedNumberOfSockets 418 * @param bool $expectInitialProgressMessage 419 * @param bool $expectReloadingMessage 420 * 421 * @return bool 422 * @throws \Exception 423 */ 424 public function expectReloadingLines( 425 int $expectedNumberOfSockets, 426 bool $expectInitialProgressMessage = true, 427 bool $expectReloadingMessage = true 428 ): bool { 429 if ($expectInitialProgressMessage && ! $this->expectNotice('Reloading in progress ...')) { 430 return false; 431 } 432 if ($expectReloadingMessage && ! $this->expectNotice('reloading: .*')) { 433 return false; 434 } 435 436 for ($i = 0; $i < $expectedNumberOfSockets; $i++) { 437 if ( ! $this->expectNotice('using inherited socket fd=\d+, "[^"]+"')) { 438 return false; 439 } 440 } 441 442 return $this->expectStartingLines(); 443 } 444 445 /** 446 * Expect log entries for reloading logs. 447 * 448 * @return bool 449 * @throws \Exception 450 */ 451 public function expectReloadingLogsLines(): bool 452 { 453 return ( 454 $this->expectNotice('error log file re-opened') && 455 $this->expectNotice('access log file re-opened') 456 ); 457 } 458 459 /** 460 * Expect starting lines when FPM starts. 461 * 462 * @return bool 463 * @throws \Exception 464 */ 465 public function expectStartingLines(): bool 466 { 467 if ($this->getError()) { 468 return false; 469 } 470 471 return ( 472 $this->expectNotice('fpm is running, pid \d+') && 473 $this->expectNotice('ready to handle connections') 474 ); 475 } 476 477 /** 478 * Expect termination lines when FPM terminates. 479 * 480 * @return bool 481 * @throws \Exception 482 */ 483 public function expectTerminatorLines(): bool 484 { 485 if ($this->getError()) { 486 return false; 487 } 488 489 return ( 490 $this->expectNotice('Terminating ...') && 491 $this->expectNotice('exiting, bye-bye!') 492 ); 493 } 494 495 /** 496 * Get log entry matcher. 497 * 498 * @param string $type Entry type like NOTICE, WARNING, DEBUG and so on. 499 * @param string $expectedMessage Message to search for 500 * @param string $pool Pool that is used and prefixes the message. 501 * @param string $ignoreErrorFor Ignore error for supplied string in the message. 502 * 503 * @return callable 504 */ 505 private function getEntryMatcher( 506 string $type, 507 string $expectedMessage, 508 ?string $pool, 509 string $ignoreErrorFor 510 ): callable { 511 if ($pool !== null) { 512 $expectedMessage = '\[pool ' . $pool . '\] ' . $expectedMessage; 513 } 514 $this->trace("Matching EXPECTED: $expectedMessage"); 515 516 $pattern = sprintf('/^(?:%s )?%s: %s(%s)$/', self::P_TIME, $type, self::P_DEBUG, $expectedMessage); 517 $this->trace("PATTERN: $pattern"); 518 519 return function ($line) use ($expectedMessage, $pattern, $type, $ignoreErrorFor) { 520 $line = rtrim($line); 521 if (preg_match($pattern, $line, $matches) === 0) { 522 if ($this->getError()) { // quick bail out to save some CPU 523 return false; 524 } 525 526 // get actual message 527 $types = implode('|', [self::NOTICE, self::WARNING, self::ERROR, self::ALERT]); 528 $errorPattern = sprintf('/^(?:%s )?(%s): %s(.*)$/', self::P_TIME, $types, self::P_DEBUG); 529 if (preg_match($errorPattern, $line, $matches) === 0) { 530 $actualMessage = null; 531 } else { 532 $expectedMessage = $type . ' - ' . $expectedMessage; 533 $actualMessage = $matches[1] . ' - ' . $matches[2]; 534 } 535 536 return $this->error( 537 $this->getMatchDebugMessage( 538 'Most likely invalid match for entry', 539 $pattern, 540 $line, 541 $expectedMessage, 542 $actualMessage 543 ), 544 $line, 545 $ignoreErrorFor 546 ); 547 } 548 $this->trace("Matched ACTUAL: " . $matches[1]); 549 550 return true; 551 }; 552 } 553 554 /** 555 * Read all log entries until timeout. 556 * 557 * @param string $type Entry type like NOTICE, WARNING, DEBUG and so on. 558 * @param string $expectedMessage Message to search for 559 * @param string|null $pool Pool that is used and prefixes the message. 560 * @param string $ignoreErrorFor Ignore error for supplied string in the message. 561 * 562 * @return bool 563 * @throws \Exception 564 */ 565 public function readAllEntries( 566 string $type, 567 string $expectedMessage, 568 string $pool = null, 569 string $ignoreErrorFor = self::DEBUG 570 ): bool { 571 if ($this->getError()) { 572 return false; 573 } 574 575 $matcher = $this->getEntryMatcher($type, $expectedMessage, $pool, $ignoreErrorFor); 576 577 while ($this->logReader->readUntil($matcher)) { 578 $this->popError(); 579 } 580 $this->popError(); 581 582 return true; 583 } 584 585 /** 586 * Expect log entry. 587 * 588 * @param string $type Entry type like NOTICE, WARNING, DEBUG and so on. 589 * @param string $expectedMessage Message to search for 590 * @param string|null $pool Pool that is used and prefixes the message. 591 * @param string $ignoreErrorFor Ignore error for supplied string in the message. 592 * @param bool $checkAllLogs Whether to also check past logs. 593 * @param bool $invert Whether the log entry is not expected rather than expected. 594 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 595 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 596 * 597 * @return bool 598 * @throws \Exception 599 */ 600 public function expectEntry( 601 string $type, 602 string $expectedMessage, 603 string $pool = null, 604 string $ignoreErrorFor = self::DEBUG, 605 bool $checkAllLogs = false, 606 bool $invert = false, 607 int $timeoutSeconds = null, 608 int $timeoutMicroseconds = null 609 ): bool { 610 if ($this->getError()) { 611 return false; 612 } 613 614 $matchResult = $this->match( 615 $this->getEntryMatcher($type, $expectedMessage, $pool, $ignoreErrorFor), 616 $invert ? null : "The $type does not match expected message", 617 $checkAllLogs, 618 $timeoutSeconds, 619 $timeoutMicroseconds 620 ); 621 622 if ($matchResult && $invert) { 623 return $this->error("The $type matches unexpected message"); 624 } 625 626 return $matchResult; 627 } 628 629 /** 630 * Expect debug log entry. 631 * 632 * @param string $expectedMessage 633 * @param string|null $pool 634 * 635 * @return bool 636 * @throws \Exception 637 */ 638 public function expectDebug(string $expectedMessage, string $pool = null): bool 639 { 640 return $this->expectEntry(self::DEBUG, $expectedMessage, $pool, self::ERROR); 641 } 642 643 /** 644 * Expect notice log entry. 645 * 646 * @param string $expectedMessage 647 * @param string|null $pool 648 * 649 * @return bool 650 * @throws \Exception 651 */ 652 public function expectNotice(string $expectedMessage, string $pool = null): bool 653 { 654 return $this->expectEntry(self::NOTICE, $expectedMessage, $pool); 655 } 656 657 /** 658 * Expect warning log entry. 659 * 660 * @param string $expectedMessage 661 * @param string|null $pool 662 * 663 * @return bool 664 * @throws \Exception 665 */ 666 public function expectWarning(string $expectedMessage, string $pool = null): bool 667 { 668 return $this->expectEntry(self::WARNING, $expectedMessage, $pool); 669 } 670 671 /** 672 * Expect error log entry. 673 * 674 * @param string $expectedMessage 675 * @param string|null $pool 676 * 677 * @return bool 678 * @throws \Exception 679 */ 680 public function expectError(string $expectedMessage, string $pool = null): bool 681 { 682 return $this->expectEntry(self::ERROR, $expectedMessage, $pool); 683 } 684 685 /** 686 * Expect alert log entry. 687 * 688 * @param string $expectedMessage 689 * @param string|null $pool 690 * 691 * @return bool 692 * @throws \Exception 693 */ 694 public function expectAlert(string $expectedMessage, string $pool = null): bool 695 { 696 return $this->expectEntry(self::ALERT, $expectedMessage, $pool); 697 } 698 699 /** 700 * Expect pattern in the log line. 701 * 702 * @param string $pattern Pattern to use. 703 * @param bool $invert Whether to expect pattern not to match. 704 * @param bool $checkAllLogs Whether to also check past logs. 705 * @param int|null $timeoutSeconds Timeout in seconds for reading of all messages. 706 * @param int|null $timeoutMicroseconds Additional timeout in microseconds for reading of all messages. 707 * 708 * @return bool 709 * @throws \Exception 710 */ 711 public function expectPattern( 712 string $pattern, 713 bool $invert = false, 714 bool $checkAllLogs = false, 715 int $timeoutSeconds = null, 716 int $timeoutMicroseconds = null, 717 ): bool { 718 $matchResult = $this->match( 719 function ($line) use ($pattern) { 720 if (preg_match($pattern, $line) === 1) { 721 $this->traceMatch("Pattern expectation", $pattern, $line); 722 723 return true; 724 } 725 726 return false; 727 }, 728 $invert ? null : 'The search pattern not found', 729 $checkAllLogs, 730 $timeoutSeconds, 731 $timeoutMicroseconds 732 ); 733 734 if ($invert && $matchResult) { 735 return $this->logReader->printError('The search pattern found - PATTERN: ' . $pattern); 736 } 737 738 return $matchResult; 739 } 740 741 /** 742 * Get match debug message. 743 * 744 * @param string $title 745 * @param string|null $pattern 746 * @param string|null $line 747 * @param string|null $expectedMessage 748 * @param string|null $actualMessage 749 * 750 * @return string 751 */ 752 private function getMatchDebugMessage( 753 string $title, 754 string $pattern = null, 755 string $line = null, 756 string $expectedMessage = null, 757 string $actualMessage = null 758 ): string { 759 $msg = "$title:\n"; 760 if ($pattern !== null) { 761 $msg .= "- PATTERN: $pattern\n"; 762 } 763 if ($line !== null) { 764 $msg .= "- LINE: $line\n"; 765 } 766 if ($expectedMessage !== null) { 767 $msg .= "- EXPECTED: $expectedMessage\n"; 768 } 769 if ($actualMessage !== null) { 770 $msg .= "- ACTUAL: $actualMessage\n"; 771 } 772 773 return $msg; 774 } 775 776 /** 777 * Print tracing of the match. 778 * 779 * @param string $title 780 * @param string|null $pattern 781 * @param string|null $line 782 * @param string|null $expectedMessage 783 * @param string|null $actualMessage 784 */ 785 private function traceMatch( 786 string $title, 787 string $pattern = null, 788 string $line = null, 789 string $expectedMessage = null, 790 string $actualMessage = null 791 ): void { 792 if ($this->debug) { 793 echo "LogTool - " . $this->getMatchDebugMessage($title, $pattern, $line, $expectedMessage, $actualMessage); 794 } 795 } 796 797 /** 798 * Print tracing message - only in debug . 799 * 800 * @param string $msg Message to print. 801 */ 802 private function trace(string $msg): void 803 { 804 if ($this->debug) { 805 print "LogTool - $msg\n"; 806 } 807 } 808 809 810 /** 811 * Save error message if the line does not contain ignored string. 812 * 813 * @param string $msg 814 * @param string|null $line 815 * @param string $ignoreFor 816 * 817 * @return false 818 */ 819 private function error(string $msg, string $line = null, string $ignoreFor = self::DEBUG): bool 820 { 821 if ($this->error === null && ($line === null || ! str_contains($line, $ignoreFor))) { 822 $this->trace("Setting error: $msg"); 823 $this->error = $msg; 824 } 825 826 return false; 827 } 828 829 /** 830 * Get saved error. 831 * 832 * @return string|null 833 */ 834 public function getError(): ?string 835 { 836 return $this->error; 837 } 838 839 /** 840 * Get saved error and clear it. 841 * 842 * @return string|null 843 */ 844 public function popError(): ?string 845 { 846 $error = $this->error; 847 $this->error = null; 848 if ($error !== null) { 849 $this->trace("Clearing error: $error"); 850 } 851 852 return $error; 853 } 854} 855