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