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