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