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