Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/Automate/Command/ExecCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
19 changes: 18 additions & 1 deletion src/Automate/Workflow/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Automate\Model\Project;
use Automate\Ssh\SshFactory;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Process\Process;

class Context
{
Expand Down Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions tests/Automate/Workflow/ContextTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

/*
* This file is part of the Automate package.
*
* (c) Julien Jacottet <jjacottet@gmail.com>
*
* 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<string, array{string, string}>
*/
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;
}
}
Loading