Passed
Push — develop ( 2a83e2...38cefb )
by Nikolay
05:24
created

Processes::mwExec()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 10
c 0
b 0
f 0
dl 0
loc 16
rs 9.9332
cc 4
nc 3
nop 3
1
<?php
2
3
/*
4
 * MikoPBX - free phone system for small business
5
 * Copyright © 2017-2023 Alexey Portnov and Nikolay Beketov
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation; either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License along with this program.
18
 * If not, see <https://www.gnu.org/licenses/>.
19
 */
20
21
namespace MikoPBX\Core\System;
22
23
use InvalidArgumentException;
24
use MikoPBX\Core\Workers\Cron\WorkerSafeScriptsCore;
25
use Phalcon\Di\Di;
26
use RuntimeException;
27
use Throwable;
28
29
/**
30
 * Class Processes
31
 *
32
 * Manages system and PHP processes with enhanced safety and reliability.
33
 *
34
 * @package MikoPBX\Core\System
35
 */
36
class Processes
37
{
38
    /**
39
     * Default timeout for background process execution in seconds
40
    */
41
    private const DEFAULT_BG_TIMEOUT = 4;
42
43
    /**
44
     * Default number of attempts for safe daemon start
45
     */
46
    private const DEFAULT_START_ATTEMPTS = 20;
47
48
    /**
49
     * Default timeout between attempts in microseconds
50
     */
51
    private const DEFAULT_ATTEMPT_TIMEOUT = 1000000;
52
53
    /**
54
     * Default output file for process logs
55
     */
56
    private const DEFAULT_OUTPUT_FILE = '/dev/null';
57
58
    /**
59
     * Directory for temporary shell scripts
60
     */
61
    private const TEMP_SCRIPTS_DIR = '/tmp';
62
63
    /**
64
     * Valid process actions
65
     */
66
    private const VALID_ACTIONS = ['start', 'stop', 'restart', 'status'];
67
68
    /**
69
     * Validates process action.
70
     *
71
     * @param string $action Action to validate
72
     * @return bool
73
     */
74
    private static function isValidAction(string $action): bool
75
    {
76
        return in_array($action, self::VALID_ACTIONS, true);
77
    }
78
79
    /**
80
     * Checks if a process is running.
81
     *
82
     * @param string $processIdOrName Process ID or name
83
     * @return bool
84
     */
85
    public static function isProcessRunning(string $processIdOrName): bool
86
    {
87
        if (is_numeric($processIdOrName)) {
88
            // Check by PID
89
            return file_exists("/proc/$processIdOrName");
90
        }
91
        // Check by process name
92
        return self::getPidOfProcess($processIdOrName) !== '';
93
    }
94
95
    /**
96
     * Safely terminates a process with timeout.
97
     *
98
     * @param string $pid Process ID to terminate
99
     * @param int $timeout Timeout in seconds
100
     * @return bool Success status
101
     */
102
    private static function safeTerminateProcess(string $pid, int $timeout = 10): bool
103
    {
104
        if (empty($pid)) {
105
            return false;
106
        }
107
108
        $kill = Util::which('kill');
109
110
        // First try SIGTERM
111
        self::mwExec("$kill -SIGTERM $pid");
112
113
        // Wait for process to terminate
114
        $startTime = time();
115
        while (time() - $startTime < $timeout) {
116
            if (!self::isProcessRunning($pid)) {
117
                return true;
118
            }
119
            usleep(100000); // 100ms
120
        }
121
122
        // Force kill if still running
123
        self::mwExec("$kill -9 $pid");
124
        return !self::isProcessRunning($pid);
125
    }
126
127
128
    /**
129
     * Waits for a process to start.
130
     *
131
     * @param string $procName Process name
132
     * @param string $excludeName Process name to exclude
133
     * @param int $attempts Maximum number of attempts
134
     * @param int $timeout Timeout between attempts
135
     * @return string Process ID or empty string
136
     */
137
    private static function waitForProcessStart(
138
        string $procName,
139
        string $excludeName,
140
        int $attempts,
141
        int $timeout
142
    ): string {
143
        $attempt = 1;
144
        while ($attempt < $attempts) {
145
            $pid = self::getPidOfProcess($procName, $excludeName);
146
            if (!empty($pid)) {
147
                return $pid;
148
            }
149
            usleep($timeout);
150
            $attempt++;
151
        }
152
        return '';
153
    }
154
155
    /**
156
     * Kills a process/daemon by name.
157
     *
158
     * @param string $procName The name of the process/daemon to kill.
159
     * @return int|null The return code of the execution.
160
     */
161
    public static function killByName(string $procName): ?int
162
    {
163
        if (empty(trim($procName))) {
164
            throw new InvalidArgumentException('Process name cannot be empty');
165
        }
166
167
        $killallPath = Util::which('killall');
168
        return self::mwExec($killallPath . ' ' . escapeshellarg($procName));
169
    }
170
171
    /**
172
     * Executes a command using exec().
173
     *
174
     * @param string $command The command to execute.
175
     * @param array|null $outArr Reference to array for command output.
176
     * @param int|null $retVal Reference for return value.
177
     * @return int The return value of the execution.
178
     * @throws RuntimeException If command is empty.
179
     */
180
    public static function mwExec(string $command, ?array &$outArr = null, ?int &$retVal = null): int
181
    {
182
        if (empty(trim($command))) {
183
            throw new RuntimeException('Empty command provided to mwExec');
184
        }
185
186
        $retVal = 0;
187
        $outArr = [];
188
        $di = Di::getDefault();
189
190
        if ($di !== null && $di->getShared('config')->path('core.debugMode')) {
191
            echo "mwExec(): $command\n";
192
        } else {
193
            exec("$command 2>&1", $outArr, $retVal);
194
        }
195
        return $retVal;
196
    }
197
198
    /**
199
     * Executes a command as a background process with timeout.
200
     *
201
     * @param string $command The command to execute.
202
     * @param int $timeout The timeout value in seconds.
203
     * @param string $logName The name of the log file.
204
     */
205
    public static function mwExecBgWithTimeout(
206
        string $command,
207
        int $timeout = self::DEFAULT_BG_TIMEOUT,
0 ignored issues
show
Unused Code introduced by
The parameter $timeout is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

207
        /** @scrutinizer ignore-unused */ int $timeout = self::DEFAULT_BG_TIMEOUT,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
208
        string $logName = self::DEFAULT_OUTPUT_FILE
209
    ): void {
210
        $di = Di::getDefault();
211
212
        if ($di !== null && $di->getShared('config')->path('core.debugMode')) {
213
            echo "mwExecBg(): $command\n";
214
            return;
215
        }
216
217
        $nohup = Util::which('nohup');
218
        $timeout = Util::which('timeout');
219
        exec("$nohup $timeout $timeout $command > $logName 2>&1 &");
220
    }
221
222
    /**
223
     * Executes multiple commands sequentially.
224
     *
225
     * @param array $arrCmds The array of commands to execute.
226
     * @param array|null $out Reference to array for output.
227
     * @param string $logname The log file name.
228
     */
229
    public static function mwExecCommands(array $arrCmds, ?array &$out = [], string $logname = ''): void
230
    {
231
        $out = [];
232
        foreach ($arrCmds as $cmd) {
233
            $out[] = "$cmd;";
234
            $outCmd = [];
235
            self::mwExec($cmd, $outCmd);
236
            $out = array_merge($out, $outCmd);
0 ignored issues
show
Bug introduced by
It seems like $outCmd can also be of type null; however, parameter $arrays of array_merge() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

236
            $out = array_merge($out, /** @scrutinizer ignore-type */ $outCmd);
Loading history...
237
        }
238
239
        if ($logname !== '') {
240
            $result = implode("\n", $out);
241
            file_put_contents(
242
                self::TEMP_SCRIPTS_DIR . "/{$logname}_commands.log",
243
                $result
244
            );
245
        }
246
    }
247
248
    /**
249
     * Restarts all workers in a separate process.
250
     */
251
    public static function restartAllWorkers(): void
252
    {
253
        $workerSafeScriptsPath = Util::getFilePathByClassName(WorkerSafeScriptsCore::class);
254
        $php = Util::which('php');
255
        $workerSafeScripts = "$php -f $workerSafeScriptsPath restart > /dev/null 2> /dev/null";
256
        self::mwExec($workerSafeScripts);
257
        SystemMessages::sysLogMsg(
258
            static::class,
259
            "Service asked for WorkerSafeScriptsCore restart",
260
            LOG_DEBUG
261
        );
262
    }
263
264
    /**
265
     * Manages a PHP worker process.
266
     *
267
     * @param string $className The class name of the PHP worker.
268
     * @param string $paramForPHPWorker The parameter for the PHP worker.
269
     * @param string $action The action to perform.
270
     */
271
    public static function processPHPWorker(
272
        string $className,
273
        string $paramForPHPWorker = 'start',
274
        string $action = 'restart'
275
    ): void {
276
        if (!self::isValidAction($action)) {
277
            throw new InvalidArgumentException("Invalid action: $action");
278
        }
279
280
        SystemMessages::sysLogMsg(
281
            __METHOD__,
282
            "processPHPWorker $className action-$action",
283
            LOG_DEBUG
284
        );
285
286
        $workerPath = Util::getFilePathByClassName($className);
287
        if (empty($workerPath) || !class_exists($className)) {
288
            return;
289
        }
290
291
        $php = Util::which('php');
292
        $command = "$php -f $workerPath";
293
        $kill = Util::which('kill');
294
295
        $activeProcesses = self::getPidOfProcess($className);
296
        $processes = array_filter(explode(' ', $activeProcesses));
297
        $currentProcCount = count($processes);
298
299
        $workerObject = new $className();
300
        $neededProcCount = $workerObject->maxProc;
301
302
        self::handleWorkerAction(
303
            $action,
304
            $activeProcesses,
305
            $command,
306
            $paramForPHPWorker,
307
            $kill,
308
            $currentProcCount,
309
            $neededProcCount,
310
            $processes
311
        );
312
    }
313
314
    /**
315
     * Handles worker process actions.
316
     *
317
     * @param string $action Action to perform
318
     * @param string $activeProcesses Active process IDs
319
     * @param string $command Command to execute
320
     * @param string $paramForPHPWorker Worker parameters
321
     * @param string $kill Kill command path
322
     * @param int $currentProcCount Current process count
323
     * @param int $neededProcCount Needed process count
324
     * @param array $processes Process IDs array
325
     */
326
    private static function handleWorkerAction(
327
        string $action,
328
        string $activeProcesses,
329
        string $command,
330
        string $paramForPHPWorker,
331
        string $kill,
332
        int $currentProcCount,
333
        int $neededProcCount,
334
        array $processes
335
    ): void {
336
        switch ($action) {
337
            case 'restart':
338
                self::handleRestartAction(
339
                    $activeProcesses,
340
                    $kill,
341
                    $command,
342
                    $paramForPHPWorker,
343
                    $neededProcCount
344
                );
345
                break;
346
            case 'stop':
347
                self::handleStopAction($activeProcesses, $kill);
348
                break;
349
            case 'start':
350
                self::handleStartAction(
351
                    $currentProcCount,
352
                    $neededProcCount,
353
                    $command,
354
                    $paramForPHPWorker,
355
                    $processes,
356
                    $kill
357
                );
358
                break;
359
        }
360
    }
361
362
    /**
363
     * Handles the restart action for a worker.
364
     *
365
     * @param string $activeProcesses Active process IDs
366
     * @param string $kill Kill command path
367
     * @param string $command Command to execute
368
     * @param string $paramForPHPWorker Worker parameters
369
     * @param int $neededProcCount Needed process count
370
     */
371
    private static function handleRestartAction(
372
        string $activeProcesses,
373
        string $kill,
374
        string $command,
375
        string $paramForPHPWorker,
376
        int $neededProcCount
377
    ): void {
378
        if ($activeProcesses !== '') {
379
            self::mwExec("$kill -SIGUSR1 $activeProcesses > /dev/null 2>&1 &");
380
            self::mwExecBg("$kill -SIGTERM $activeProcesses");
381
        }
382
383
        for ($i = 0; $i < $neededProcCount; $i++) {
384
            self::mwExecBg("$command $paramForPHPWorker");
385
        }
386
    }
387
388
    /**
389
     * Handles the stop action for a worker.
390
     *
391
     * @param string $activeProcesses Active process IDs
392
     * @param string $kill Kill command path
393
     */
394
    private static function handleStopAction(string $activeProcesses, string $kill): void
395
    {
396
        if ($activeProcesses !== '') {
397
            self::mwExec("$kill -SIGUSR2 $activeProcesses > /dev/null 2>&1 &");
398
            self::mwExecBg("$kill -SIGTERM $activeProcesses");
399
        }
400
    }
401
402
    /**
403
     * Handles the start action for a worker.
404
     *
405
     * @param int $currentProcCount Current process count
406
     * @param int $neededProcCount Needed process count
407
     * @param string $command Command to execute
408
     * @param string $paramForPHPWorker Worker parameters
409
     * @param array $processes Process IDs array
410
     * @param string $kill Kill command path
411
     */
412
    private static function handleStartAction(
413
        int $currentProcCount,
414
        int $neededProcCount,
415
        string $command,
416
        string $paramForPHPWorker,
417
        array $processes,
418
        string $kill
419
    ): void {
420
        if ($currentProcCount === $neededProcCount) {
421
            return;
422
        }
423
424
        if ($neededProcCount > $currentProcCount) {
425
            for ($i = $currentProcCount; $i < $neededProcCount; $i++) {
426
                self::mwExecBg("$command $paramForPHPWorker");
427
            }
428
        } elseif ($currentProcCount > $neededProcCount) {
429
            $countProc4Kill = $currentProcCount - $neededProcCount;
430
            for ($i = 0; $i < $countProc4Kill; $i++) {
431
                if (!isset($processes[$i])) {
432
                    break;
433
                }
434
                self::mwExec("$kill -SIGUSR1 {$processes[$i]} > /dev/null 2>&1 &");
435
                self::mwExecBg("$kill -SIGTERM {$processes[$i]}");
436
            }
437
        }
438
    }
439
440
    /**
441
     * Retrieves the PID of a process by its name.
442
     *
443
     * @param string $name Process name
444
     * @param string $exclude Process name to exclude
445
     * @return string Process IDs
446
     */
447
    public static function getPidOfProcess(string $name, string $exclude = ''): string
448
    {
449
        $ps = Util::which('ps');
450
        $grep = Util::which('grep');
451
        $awk = Util::which('awk');
452
453
        $name = addslashes($name);
454
        $filterCmd = '';
455
        if (!empty($exclude)) {
456
            $filterCmd = "| $grep -v " . escapeshellarg($exclude);
457
        }
458
459
        $out = [];
460
        self::mwExec(
461
            "$ps -A -o 'pid,args' $filterCmd | $grep '$name' | $grep -v grep | $awk '{print $1}'",
462
            $out
463
        );
464
465
        return trim(implode(' ', $out));
0 ignored issues
show
Bug introduced by
It seems like $out can also be of type null; however, parameter $pieces of implode() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

465
        return trim(implode(' ', /** @scrutinizer ignore-type */ $out));
Loading history...
466
    }
467
468
    /**
469
     * Executes a command as a background process.
470
     *
471
     * @param string $command Command to execute
472
     * @param string $outFile Output file path
473
     * @param int $sleepTime Sleep time in seconds
474
     */
475
    public static function mwExecBg(
476
        string $command,
477
        string $outFile = self::DEFAULT_OUTPUT_FILE,
478
        int $sleepTime = 0
479
    ): void {
480
        $nohup = Util::which('nohup');
481
        $sh = Util::which('sh');
482
        $rm = Util::which('rm');
483
        $sleep = Util::which('sleep');
484
485
        if ($sleepTime > 0) {
486
            $filename = self::TEMP_SCRIPTS_DIR . '/' . time() . '_noop.sh';
487
            file_put_contents(
488
                $filename,
489
                "$sleep $sleepTime; $command; $rm -rf $filename"
490
            );
491
            $noopCommand = "$nohup $sh $filename > $outFile 2>&1 &";
492
        } else {
493
            $noopCommand = "$nohup $command > $outFile 2>&1 &";
494
        }
495
        exec($noopCommand);
496
    }
497
498
    /**
499
     * Manages a daemon/worker process.
500
     *
501
     * @param string $cmd Command to execute
502
     * @param string $param Command parameters
503
     * @param string $procName Process name
504
     * @param string $action Action to perform
505
     * @param string $outFile Output file path
506
     *
507
     * @return array|bool Process status or operation result
508
     * @throws InvalidArgumentException If action is invalid
509
     */
510
    public static function processWorker(
511
        string $cmd,
512
        string $param,
513
        string $procName,
514
        string $action,
515
        string $outFile = self::DEFAULT_OUTPUT_FILE
516
    ): bool|array {
517
        if (!self::isValidAction($action)) {
518
            throw new InvalidArgumentException("Invalid action: $action");
519
        }
520
521
        $kill = Util::which('kill');
522
        $nohup = Util::which('nohup');
523
        $workerPID = self::getPidOfProcess($procName);
524
525
        return match ($action) {
526
            'status' => [
527
                'status' => ($workerPID !== '') ? 'Started' : 'Stopped',
528
                'app' => $procName,
529
                'PID' => $workerPID
530
            ],
531
            'restart' => self::handleWorkerRestart($kill, $workerPID, $nohup, $cmd, $param, $outFile),
532
            'stop' => self::handleWorkerStop($kill, $workerPID),
533
            'start' => self::handleWorkerStart($workerPID, $nohup, $cmd, $param, $outFile),
534
            default => throw new InvalidArgumentException("Unsupported action: $action"),
535
        };
536
    }
537
538
    /**
539
     * Handles worker restart operation.
540
     *
541
     * @param string $kill Kill command path
542
     * @param string $workerPID Worker process ID
543
     * @param string $nohup Nohup command path
544
     * @param string $cmd Command to execute
545
     * @param string $param Command parameters
546
     * @param string $outFile Output file path
547
     * @return bool
548
     */
549
    private static function handleWorkerRestart(
550
        string $kill,
0 ignored issues
show
Unused Code introduced by
The parameter $kill is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

550
        /** @scrutinizer ignore-unused */ string $kill,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
551
        string $workerPID,
552
        string $nohup,
553
        string $cmd,
554
        string $param,
555
        string $outFile
556
    ): bool {
557
        if ($workerPID !== '') {
558
            self::safeTerminateProcess($workerPID);
559
        }
560
        self::mwExec("$nohup $cmd $param > $outFile 2>&1 &");
561
        return true;
562
    }
563
564
    /**
565
     * Handles worker stop operation.
566
     *
567
     * @param string $kill Kill command path
568
     * @param string $workerPID Worker process ID
569
     * @return bool
570
     */
571
    private static function handleWorkerStop(string $kill, string $workerPID): bool
0 ignored issues
show
Unused Code introduced by
The parameter $kill is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

571
    private static function handleWorkerStop(/** @scrutinizer ignore-unused */ string $kill, string $workerPID): bool

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
572
    {
573
        if ($workerPID !== '') {
574
            self::safeTerminateProcess($workerPID);
575
        }
576
        return true;
577
    }
578
579
    /**
580
     * Handles worker start operation.
581
     *
582
     * @param string $workerPID Worker process ID
583
     * @param string $nohup Nohup command path
584
     * @param string $cmd Command to execute
585
     * @param string $param Command parameters
586
     * @param string $outFile Output file path
587
     * @return bool
588
     */
589
    private static function handleWorkerStart(
590
        string $workerPID,
591
        string $nohup,
592
        string $cmd,
593
        string $param,
594
        string $outFile
595
    ): bool {
596
        if ($workerPID === '') {
597
            self::mwExec("$nohup $cmd $param > $outFile 2>&1 &");
598
        }
599
        return true;
600
    }
601
602
    /**
603
     * Starts a daemon process with failure control.
604
     *
605
     * @param string $procName Process name
606
     * @param string $args Process arguments
607
     * @param int $attemptsCount Number of attempts to start
608
     * @param int $timeout Timeout between attempts in microseconds
609
     * @param string $outFile Output file path
610
     * @return bool Success status
611
     */
612
    public static function safeStartDaemon(
613
        string $procName,
614
        string $args,
615
        int $attemptsCount = self::DEFAULT_START_ATTEMPTS,
616
        int $timeout = self::DEFAULT_ATTEMPT_TIMEOUT,
617
        string $outFile = self::DEFAULT_OUTPUT_FILE
618
    ): bool {
619
        $result = true;
620
        $baseName = "safe-$procName";
621
        $safeLink = "/sbin/$baseName";
622
623
        try {
624
            Util::createUpdateSymlink('/etc/rc/worker_reload', $safeLink);
625
            self::killByName($baseName);
626
            self::killByName($procName);
627
628
            // Start the process in the background
629
            self::mwExecBg("$safeLink $args", $outFile);
630
631
            // Wait for the process to start with timeout
632
            $pid = self::waitForProcessStart($procName, $baseName, $attemptsCount, $timeout);
633
634
            if (empty($pid)) {
635
                SystemMessages::echoWithSyslog(" - Wait for start '$procName' fail" . PHP_EOL);
636
                $result = false;
637
            }
638
        } catch (Throwable $e) {
639
            SystemMessages::echoWithSyslog("Error starting daemon '$procName': " . $e->getMessage());
640
            $result = false;
641
        }
642
643
        return $result;
644
    }
645
646
}
647