diff --git a/src/Automate/Command/ExecCommand.php b/src/Automate/Command/ExecCommand.php index 8819c9d..316688c 100644 --- a/src/Automate/Command/ExecCommand.php +++ b/src/Automate/Command/ExecCommand.php @@ -19,6 +19,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -57,7 +58,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $rs = $ssh->exec($command); $output->write($rs); } catch (\Exception $exception) { - $output->write($exception->getMessage()); + // the parent process reports the failure of an asynchronous hook + // from the error output: writing there is what makes the reason + // visible instead of an empty error + $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + $errorOutput->write($exception->getMessage()); return Command::FAILURE; } diff --git a/src/Automate/Workflow/Context.php b/src/Automate/Workflow/Context.php index adb9632..227c0b7 100644 --- a/src/Automate/Workflow/Context.php +++ b/src/Automate/Workflow/Context.php @@ -17,6 +17,7 @@ use Automate\Model\Project; use Automate\Ssh\SshFactory; use Symfony\Component\Filesystem\Path; +use Symfony\Component\Process\Process; class Context { @@ -116,11 +117,27 @@ public function execAsync(string $command, ?array $serversList = null, bool $add foreach ($process as $child) { $child->wait(); if (!$child->isSuccessful()) { - throw new \RuntimeException($child->getErrorOutput()); + throw new \RuntimeException($this->getProcessError($child)); } } } + /** + * Not every failure reaches the error output: fall back on the standard + * output, then on the exit code, so the deployment never stops on an empty + * error message. + */ + private function getProcessError(Process $process): string + { + foreach ([$process->getErrorOutput(), $process->getOutput()] as $output) { + if ('' !== trim($output)) { + return trim($output); + } + } + + return sprintf('The command failed with exit code %s.', $process->getExitCode() ?? 'unknown'); + } + /** * @param ?string[] $exclude * @param ?string[] $serversList diff --git a/tests/Automate/Workflow/ContextTest.php b/tests/Automate/Workflow/ContextTest.php new file mode 100644 index 0000000..5da68ad --- /dev/null +++ b/tests/Automate/Workflow/ContextTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Automate\Tests\Workflow; + +use Automate\Archiver; +use Automate\Logger\LoggerInterface; +use Automate\Model\Platform; +use Automate\Model\Project; +use Automate\Model\Server; +use Automate\Ssh\Ssh; +use Automate\Ssh\SshFactory; +use Automate\Tests\AbstractMockTestCase; +use Automate\Workflow\Context; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Process\Process; + +class ContextTest extends AbstractMockTestCase +{ + /** + * @return iterable + */ + public static function failingCommandProvider(): iterable + { + yield 'reported on the error output' => ['echo "boom" >&2; exit 1', 'boom']; + + // this is how the "exec" command reports a failure + yield 'reported on the standard output' => ['echo "boom"; exit 1', 'boom']; + + yield 'not reported at all' => ['exit 3', 'The command failed with exit code 3.']; + } + + #[DataProvider('failingCommandProvider')] + public function testExecAsyncReportsWhyTheCommandFailed(string $script, string $expected): void + { + $context = $this->createContext(static fn (): Process => new Process(['sh', '-c', $script])); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($expected); + + $context->execAsync('a command'); + } + + public function testExecAsyncSucceeds(): void + { + $context = $this->createContext(static fn (): Process => new Process(['sh', '-c', 'exit 0'])); + + $context->execAsync('a command'); + + $this->addToAssertionCount(1); + } + + /** + * @param callable(): Process $process + */ + private function createContext(callable $process): Context + { + $platform = new Platform('production'); + $sshFactory = \Mockery::mock(SshFactory::class); + + // two servers, otherwise execAsync falls back on the synchronous path + foreach (['front-01', 'front-02'] as $name) { + $server = (new Server())->setName($name)->setPath('/home/wwwroot/demo'); + $platform->addServer($server); + + $ssh = \Mockery::spy(Ssh::class); + $ssh->shouldReceive('execAsync')->andReturnUsing($process); + $sshFactory->shouldReceive('create')->with($server)->andReturns($ssh); + } + + $context = new Context( + new Project(), + $platform, + \Mockery::spy(LoggerInterface::class), + $sshFactory, + \Mockery::mock(Archiver::class), + releaseId: '2024.03.10-2340.241' + ); + + $context->connect(); + + return $context; + } +}