diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index e4334b1..95f3941 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -10,7 +10,7 @@ jobs : strategy : matrix : - php_version : [ '8.1' ] + php_version : [ '8.1', '8.2', '8.3', '8.4' ] steps : - uses : actions/checkout@v2 diff --git a/README.md b/README.md index c8aaef3..9c856a3 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,27 @@ echo $process; $process->getPayload(); ``` +### Tailing process logs + +`tailProcessStdoutLog()` and `tailProcessStderrLog()` return a `TailLogInterface` object with the log +chunk, the next offset to request, and an overflow flag. + +```php +$offset = 0; + +do { + $tail = $supervisor->tailProcessStdoutLog('my_process', $offset, 4096); + + echo $tail->getBytes(); + + $offset = $tail->getOffset(); +} while ($tail->isOverflow()); +``` + +> `FailedException` (fault 30) is thrown when the process log file does not exist yet, e.g. the +> process has never been started or was configured without a `stdout_logfile`. Wrap tail calls in a +> try/catch when the log may not exist yet. + ### Exception handling For each possible fault response there is an exception. These exceptions extend a [common exception](src/Exception/Fault.php), so you are able to catch a specific fault or all. When an unknown fault is returned from the server, an instance if the common exception is thrown. The list of fault responses and the appropriate exception can be found in the class. @@ -110,10 +131,6 @@ try { You can find the Supervisor XML-RPC documentation here: [http://supervisord.org/api.html](http://supervisord.org/api.html) -## Notice - -If you use PHP XML-RPC extension to parse responses (which is marked as *EXPERIMENTAL*). This can cause issues when you are trying to read/tail log of a PROCESS. Make sure you clean your log messages. The only information I found about this is a [comment](http://www.php.net/function.xmlrpc-decode#44213). - ## Contributing Please see [CONTRIBUTING](CONTRIBUTING.md) for details. diff --git a/composer.json b/composer.json index c7abd8c..b2968e1 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "Supervisor\\": "src/" } }, - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true, "require": { "php": ">=8.1", diff --git a/spec/ProcessSpec.php b/spec/ProcessSpec.php index 39d425c..cf73355 100644 --- a/spec/ProcessSpec.php +++ b/spec/ProcessSpec.php @@ -56,6 +56,12 @@ function it_checks_process_state() $this->checkState(2)->shouldReturn(false); } + function it_throws_when_accessing_unknown_key() + { + $this->shouldThrow(\OutOfBoundsException::class) + ->duringOffsetGet('nonexistent_key'); + } + function it_throws_an_exception_when_being_altered_by_calling_offset_set() { $this->shouldThrow(\LogicException::class) diff --git a/spec/ReloadResultSpec.php b/spec/ReloadResultSpec.php new file mode 100644 index 0000000..a2561cc --- /dev/null +++ b/spec/ReloadResultSpec.php @@ -0,0 +1,65 @@ +beConstructedWith(['added'], ['modified'], ['removed']); + } + + function it_is_initializable() + { + $this->shouldHaveType(ReloadResult::class); + } + + function it_implements_reload_result_interface() + { + $this->shouldImplement(ReloadResultInterface::class); + } + + function it_returns_added_groups() + { + $this->getAdded()->shouldReturn(['added']); + } + + function it_returns_modified_groups() + { + $this->getModified()->shouldReturn(['modified']); + } + + function it_returns_removed_groups() + { + $this->getRemoved()->shouldReturn(['removed']); + } + + function it_returns_all_affected_groups() + { + $this->getAffected()->shouldReturn(['added', 'modified', 'removed']); + } + + function it_can_be_built_from_reload_config_response() + { + $this->beConstructedThrough('fromReloadConfig', [[ + [['added_group'], ['modified_group'], ['removed_group']] + ]]); + + $this->getAdded()->shouldReturn(['added_group']); + $this->getModified()->shouldReturn(['modified_group']); + $this->getRemoved()->shouldReturn(['removed_group']); + } + + function it_handles_empty_reload_config_response() + { + $this->beConstructedThrough('fromReloadConfig', [[]]); + + $this->getAdded()->shouldReturn([]); + $this->getModified()->shouldReturn([]); + $this->getRemoved()->shouldReturn([]); + } +} diff --git a/spec/StateInfoSpec.php b/spec/StateInfoSpec.php new file mode 100644 index 0000000..72a27f0 --- /dev/null +++ b/spec/StateInfoSpec.php @@ -0,0 +1,44 @@ +beConstructedWith(ServiceStates::Running, 'RUNNING'); + } + + function it_is_initializable() + { + $this->shouldHaveType(StateInfo::class); + } + + function it_implements_state_info_interface() + { + $this->shouldImplement(StateInfoInterface::class); + } + + function it_returns_state_code() + { + $this->getStateCode()->shouldReturn(ServiceStates::Running); + } + + function it_returns_state_name() + { + $this->getStateName()->shouldReturn('RUNNING'); + } + + function it_can_be_built_from_get_state_response() + { + $this->beConstructedThrough('fromGetState', [['statecode' => 1, 'statename' => 'RUNNING']]); + + $this->getStateCode()->shouldReturn(ServiceStates::Running); + $this->getStateName()->shouldReturn('RUNNING'); + } +} diff --git a/spec/SupervisorSpec.php b/spec/SupervisorSpec.php index 6b6aa64..e9f66be 100644 --- a/spec/SupervisorSpec.php +++ b/spec/SupervisorSpec.php @@ -5,7 +5,11 @@ use fXmlRpc\ClientInterface; use PhpSpec\ObjectBehavior; use Supervisor\Process; +use Supervisor\ReloadResult; +use Supervisor\ServiceStates; +use Supervisor\StateInfo; use Supervisor\Supervisor; +use Supervisor\TailLog; class SupervisorSpec extends ObjectBehavior { @@ -44,7 +48,7 @@ function it_calls_a_method(ClientInterface $client) function it_checks_if_supervisor_is_running(ClientInterface $client) { $client->call('supervisor.getState', []) - ->willReturn(['statecode' => 1]); + ->willReturn(['statecode' => 1, 'statename' => 'RUNNING']); $this->isRunning()->shouldReturn(true); } @@ -52,7 +56,7 @@ function it_checks_if_supervisor_is_running(ClientInterface $client) function it_checks_supervisor_state(ClientInterface $client) { $client->call('supervisor.getState', []) - ->willReturn(['statecode' => 1]); + ->willReturn(['statecode' => 1, 'statename' => 'RUNNING']); $this->checkState(1)->shouldReturn(true); } @@ -83,4 +87,100 @@ function it_returns_a_process_(ClientInterface $client) $process->shouldHaveType(Process::class); $process->getName()->shouldReturn('process_name'); } + + function it_returns_state(ClientInterface $client) + { + $client->call('supervisor.getState', []) + ->willReturn(['statecode' => 1, 'statename' => 'RUNNING']); + + $result = $this->getState(); + + $result->shouldHaveType(StateInfo::class); + $result->getStateCode()->shouldReturn(ServiceStates::Running); + $result->getStateName()->shouldReturn('RUNNING'); + } + + function it_tails_process_stdout_log(ClientInterface $client) + { + $client->call('supervisor.tailProcessStdoutLog', ['process_name', 0, 100]) + ->willReturn(['log content', 11, false]); + + $result = $this->tailProcessStdoutLog('process_name', 0, 100); + + $result->shouldHaveType(TailLog::class); + $result->getBytes()->shouldReturn('log content'); + $result->getOffset()->shouldReturn(11); + $result->isOverflow()->shouldReturn(false); + } + + function it_tails_process_stderr_log(ClientInterface $client) + { + $client->call('supervisor.tailProcessStderrLog', ['process_name', 0, 100]) + ->willReturn(['error content', 13, true]); + + $result = $this->tailProcessStderrLog('process_name', 0, 100); + + $result->shouldHaveType(TailLog::class); + $result->getBytes()->shouldReturn('error content'); + $result->getOffset()->shouldReturn(13); + $result->isOverflow()->shouldReturn(true); + } + + function it_removes_groups_on_reload(ClientInterface $client) + { + $client->call('supervisor.reloadConfig', [])->willReturn([[[], [], ['old_group']]]); + $client->call('supervisor.stopProcessGroup', ['old_group', true])->willReturn([]); + $client->call('supervisor.removeProcessGroup', ['old_group'])->willReturn(true); + + $result = $this->reloadAndApplyConfig(); + + $result->shouldHaveType(ReloadResult::class); + $result->getRemoved()->shouldReturn(['old_group']); + } + + function it_restarts_modified_groups_on_reload(ClientInterface $client) + { + $client->call('supervisor.reloadConfig', [])->willReturn([[[], ['changed_group'], []]]); + $client->call('supervisor.stopProcessGroup', ['changed_group', true])->willReturn([]); + $client->call('supervisor.removeProcessGroup', ['changed_group'])->willReturn(true); + $client->call('supervisor.addProcessGroup', ['changed_group'])->willReturn(true); + $client->call('supervisor.startProcessGroup', ['changed_group'])->willReturn([]); + + $result = $this->reloadAndApplyConfig(); + + $result->shouldHaveType(ReloadResult::class); + $result->getModified()->shouldReturn(['changed_group']); + } + + function it_does_not_stop_modified_groups_when_flag_is_false(ClientInterface $client) + { + $client->call('supervisor.reloadConfig', [])->willReturn([[[], ['changed_group'], []]]); + $client->call('supervisor.stopProcessGroup', ['changed_group', true])->shouldNotBeCalled(); + $client->call('supervisor.removeProcessGroup', ['changed_group'])->shouldNotBeCalled(); + $client->call('supervisor.addProcessGroup', ['changed_group'])->willReturn(true); + $client->call('supervisor.startProcessGroup', ['changed_group'])->willReturn([]); + + $this->reloadAndApplyConfig(true, false, true); + } + + function it_starts_added_groups_on_reload(ClientInterface $client) + { + $client->call('supervisor.reloadConfig', [])->willReturn([[['new_group'], [], []]]); + $client->call('supervisor.addProcessGroup', ['new_group'])->willReturn(true); + $client->call('supervisor.startProcessGroup', ['new_group'])->willReturn([]); + + $result = $this->reloadAndApplyConfig(); + + $result->shouldHaveType(ReloadResult::class); + $result->getAdded()->shouldReturn(['new_group']); + } + + function it_does_not_start_new_processes_when_flag_is_false(ClientInterface $client) + { + $client->call('supervisor.reloadConfig', [])->willReturn([[['new_group'], [], []]]); + $client->call('supervisor.addProcessGroup', ['new_group'])->willReturn(true); + $client->call('supervisor.startProcessGroup', ['new_group'])->shouldNotBeCalled(); + + $this->reloadAndApplyConfig(true, true, false); + } } diff --git a/spec/TailLogSpec.php b/spec/TailLogSpec.php new file mode 100644 index 0000000..2d7c367 --- /dev/null +++ b/spec/TailLogSpec.php @@ -0,0 +1,56 @@ +beConstructedWith('log content', 11, false); + } + + function it_is_initializable() + { + $this->shouldHaveType(TailLog::class); + } + + function it_implements_tail_log_interface() + { + $this->shouldImplement(TailLogInterface::class); + } + + function it_returns_bytes() + { + $this->getBytes()->shouldReturn('log content'); + } + + function it_returns_offset() + { + $this->getOffset()->shouldReturn(11); + } + + function it_returns_overflow_flag() + { + $this->isOverflow()->shouldReturn(false); + } + + function it_can_be_built_from_tail_log_response() + { + $this->beConstructedThrough('fromTailLog', [['log content', 11, false]]); + + $this->getBytes()->shouldReturn('log content'); + $this->getOffset()->shouldReturn(11); + $this->isOverflow()->shouldReturn(false); + } + + function it_casts_overflow_to_bool_when_built_from_response() + { + $this->beConstructedThrough('fromTailLog', [['data', 5, 1]]); + + $this->isOverflow()->shouldReturn(true); + } +} diff --git a/src/Exception/FaultCodes.php b/src/Exception/FaultCodes.php index b339367..81c1653 100644 --- a/src/Exception/FaultCodes.php +++ b/src/Exception/FaultCodes.php @@ -29,6 +29,7 @@ enum FaultCodes: int case StillRunning = 91; case CantReread = 92; + /** @return class-string */ public function getExceptionClass(): string { return match($this) { diff --git a/src/Process.php b/src/Process.php index 8d803ad..088651d 100644 --- a/src/Process.php +++ b/src/Process.php @@ -2,7 +2,6 @@ namespace Supervisor; -use ReturnTypeWillChange; /** * Process object holding data for a single process. @@ -12,6 +11,7 @@ */ final class Process implements ProcessInterface { + /** @param array $payload */ public function __construct( private readonly array $payload = [] ) { @@ -20,6 +20,7 @@ public function __construct( /** * @inheritDoc */ + /** @return array */ public function getPayload(): array { return $this->payload; @@ -56,6 +57,9 @@ public function checkState(int|ProcessStates $state): bool { if (is_int($state)) { $state = ProcessStates::tryFrom($state); + if ($state === null) { + return false; + } } return $this->getState() === $state; @@ -74,7 +78,10 @@ public function __toString(): string */ public function offsetGet($offset): mixed { - return $this->payload[$offset] ?? null; + if (!array_key_exists($offset, $this->payload)) { + throw new \OutOfBoundsException(sprintf('Unknown process key "%s"', $offset)); + } + return $this->payload[$offset]; } /** diff --git a/src/ProcessInterface.php b/src/ProcessInterface.php index 6bf5e20..805d1bb 100644 --- a/src/ProcessInterface.php +++ b/src/ProcessInterface.php @@ -7,12 +7,14 @@ * * @author Márk Sági-Kazár * @author Buster Neece + * @extends \ArrayAccess */ interface ProcessInterface extends \ArrayAccess { /** * Returns the process info array. */ + /** @return array */ public function getPayload(): array; /** @@ -26,7 +28,7 @@ public function getName(): string; public function isRunning(): bool; /** - * Checks whether the process is running. + * Returns the current process state. */ public function getState(): ProcessStates; diff --git a/src/ReloadResult.php b/src/ReloadResult.php index 10e39e1..8aef553 100644 --- a/src/ReloadResult.php +++ b/src/ReloadResult.php @@ -9,6 +9,11 @@ */ final class ReloadResult implements ReloadResultInterface { + /** + * @param list $added + * @param list $modified + * @param list $removed + */ public function __construct( private readonly array $added = [], private readonly array $modified = [], @@ -47,6 +52,7 @@ public function getRemoved(): array return $this->removed; } + /** @param array $reloadResult */ public static function fromReloadConfig(array $reloadResult): self { [$added, $modified, $removed] = $reloadResult[0] ?? [null, null, null]; diff --git a/src/StateInfo.php b/src/StateInfo.php new file mode 100644 index 0000000..7b83204 --- /dev/null +++ b/src/StateInfo.php @@ -0,0 +1,32 @@ +stateCode; + } + + public function getStateName(): string + { + return $this->stateName; + } +} diff --git a/src/StateInfoInterface.php b/src/StateInfoInterface.php new file mode 100644 index 0000000..397bdbe --- /dev/null +++ b/src/StateInfoInterface.php @@ -0,0 +1,12 @@ + $arguments */ public function call(string $namespace, string $method, array $arguments = []): mixed { try { @@ -85,6 +82,7 @@ public function call(string $namespace, string $method, array $arguments = []): /** * @inheritDoc */ + /** @param array $arguments */ public function __call(string $method, array $arguments) { return $this->call('supervisor', $method, $arguments); @@ -98,12 +96,23 @@ public function isConnected(): bool try { $this->call('system', 'listMethods'); } catch (\Exception $e) { + $this->logger->debug('Could not connect to supervisord.', ['error' => $e->getMessage()]); return false; } return true; } + /** + * @inheritDoc + */ + public function getState(): StateInfoInterface + { + return StateInfo::fromGetState( + $this->call('supervisor', 'getState', []) + ); + } + /** * @inheritDoc */ @@ -117,7 +126,7 @@ public function isRunning(): bool */ public function getServiceState(): ServiceStates { - return ServiceStates::from($this->getState()['statecode']); + return $this->getState()->getStateCode(); } /** @@ -127,6 +136,9 @@ public function checkState(int|ServiceStates $checkState): bool { if (is_int($checkState)) { $checkState = ServiceStates::tryFrom($checkState); + if ($checkState === null) { + return false; + } } return $this->getServiceState() === $checkState; @@ -156,6 +168,26 @@ public function getProcess(string $name): ProcessInterface return new Process($process); } + /** + * @inheritDoc + */ + public function tailProcessStdoutLog(string $name, int $offset, int $limit): TailLogInterface + { + return TailLog::fromTailLog( + $this->call('supervisor', 'tailProcessStdoutLog', [$name, $offset, $limit]) + ); + } + + /** + * @inheritDoc + */ + public function tailProcessStderrLog(string $name, int $offset, int $limit): TailLogInterface + { + return TailLog::fromTailLog( + $this->call('supervisor', 'tailProcessStderrLog', [$name, $offset, $limit]) + ); + } + /** * @inheritDoc */ diff --git a/src/SupervisorInterface.php b/src/SupervisorInterface.php index c32af08..57860d3 100644 --- a/src/SupervisorInterface.php +++ b/src/SupervisorInterface.php @@ -2,7 +2,6 @@ namespace Supervisor; -use Supervisor\Exception\ReloadExceptions; use Supervisor\Exception\SupervisorException; /** @@ -16,27 +15,24 @@ * @method string getAPIVersion() * @method string getSupervisorVersion() * @method string getIdentification() - * @method array getState() * @method int getPID() - * @method string readLog(integer $offset, integer $limit) + * @method string readLog(int $offset, int $limit) * @method bool clearLog() * @method bool shutdown() * @method bool restart() * @method array getProcessInfo(string $processName) * @method array getAllProcessInfo() - * @method bool startProcess(string $name, boolean $wait = true) - * @method array startAllProcesses(boolean $wait = true) - * @method array startProcessGroup(string $name, boolean $wait = true) - * @method bool stopProcess(string $name, boolean $wait = true) - * @method array stopAllProcesses(boolean $wait = true) - * @method array stopProcessGroup(string $name, boolean $wait = true) + * @method bool startProcess(string $name, bool $wait = true) + * @method array startAllProcesses(bool $wait = true) + * @method array startProcessGroup(string $name, bool $wait = true) + * @method bool stopProcess(string $name, bool $wait = true) + * @method array stopAllProcesses(bool $wait = true) + * @method array stopProcessGroup(string $name, bool $wait = true) * @method bool sendProcessStdin(string $name, string $chars) * @method bool addProcessGroup(string $name) * @method bool removeProcessGroup(string $name) - * @method string readProcessStdoutLog(string $name, integer $offset, integer $limit) - * @method string readProcessStderrLog(string $name, integer $offset, integer $limit) - * @method array tailProcessStdoutLog(string $name, integer $offset, integer $limit) - * @method array tailProcessStderrLog(string $name, integer $offset, integer $limit) + * @method string readProcessStdoutLog(string $name, int $offset, int $limit) + * @method string readProcessStderrLog(string $name, int $offset, int $limit) * @method bool clearProcessLogs(string $name) * @method array clearAllProcessLogs() * @method array reloadConfig() @@ -51,7 +47,7 @@ interface SupervisorInterface * * @param string $namespace * @param string $method - * @param array $arguments + * @param array $arguments * * @return mixed */ @@ -63,7 +59,7 @@ public function call(string $namespace, string $method, array $arguments = []): * Handles all calls to supervisor namespace * * @param string $method - * @param array $arguments + * @param array $arguments * * @return mixed */ @@ -81,6 +77,8 @@ public function isConnected(): bool; */ public function isRunning(): bool; + public function getState(): StateInfoInterface; + /** * Get the supervisord service state. */ @@ -107,6 +105,10 @@ public function getAllProcesses(): array; */ public function getProcess(string $name): ProcessInterface; + public function tailProcessStdoutLog(string $name, int $offset, int $limit): TailLogInterface; + + public function tailProcessStderrLog(string $name, int $offset, int $limit): TailLogInterface; + /** * Reload configuration and apply process changes immediately, i.e.: * - Start any processes newly added in the configuration, diff --git a/src/TailLog.php b/src/TailLog.php new file mode 100644 index 0000000..ab36e5a --- /dev/null +++ b/src/TailLog.php @@ -0,0 +1,39 @@ +bytes; + } + + public function getOffset(): int + { + return $this->offset; + } + + public function isOverflow(): bool + { + return $this->overflow; + } +} diff --git a/src/TailLogInterface.php b/src/TailLogInterface.php new file mode 100644 index 0000000..a3b83fb --- /dev/null +++ b/src/TailLogInterface.php @@ -0,0 +1,17 @@ +