Skip to content

Commit

Permalink
Merge pull request #1809 from brefphp/cli-response
Browse files Browse the repository at this point in the history
The Console runtime now always returns the command output
  • Loading branch information
mnapoli authored Jun 18, 2024
2 parents d7f970f + ccf9686 commit 5690111
Show file tree
Hide file tree
Showing 8 changed files with 233 additions and 118 deletions.
5 changes: 5 additions & 0 deletions src/Bref.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,10 @@ public static function reset(): void
{
self::$containerProvider = null;
self::$container = null;
self::$hooks = [
'beforeStartup' => [],
'beforeInvoke' => [],
];
self::$eventDispatcher = new EventDispatcher;
}
}
9 changes: 9 additions & 0 deletions src/ConsoleRuntime/CommandFailed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php declare(strict_types=1);

namespace Bref\ConsoleRuntime;

use Exception;

class CommandFailed extends Exception
{
}
15 changes: 11 additions & 4 deletions src/ConsoleRuntime/Main.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
use Bref\Context\Context;
use Bref\LazySecretsLoader;
use Bref\Runtime\LambdaRuntime;
use Exception;
use Symfony\Component\Process\Process;

/**
Expand Down Expand Up @@ -44,7 +43,7 @@ public static function run(): void
}

$timeout = max(1, $context->getRemainingTimeInMillis() / 1000 - 1);
$command = sprintf('/opt/bin/php %s %s 2>&1', $handlerFile, $cliOptions);
$command = sprintf('php %s %s 2>&1', $handlerFile, $cliOptions);
$process = Process::fromShellCommandline($command, null, null, null, $timeout);

$process->run(function ($type, $buffer): void {
Expand All @@ -53,13 +52,21 @@ public static function run(): void

$exitCode = $process->getExitCode();

$output = $process->getOutput();
// Trim the output to stay under the 6MB limit for AWS Lambda
// We only keep 5MB because at this point the difference won't be important
// and we'll serialize the output to JSON which will add some overhead
$output = substr($output, max(0, strlen($output) - 5 * 1024 * 1024));

if ($exitCode > 0) {
throw new Exception('The command exited with a non-zero status code: ' . $exitCode);
// This needs to be thrown so that AWS Lambda knows the invocation failed
// (e.g. important for error rates in CloudWatch)
throw new CommandFailed($output);
}

return [
'exitCode' => $exitCode, // will always be 0
'output' => $process->getOutput(),
'output' => $output,
];
});
}
Expand Down
63 changes: 61 additions & 2 deletions tests/ConsoleRuntime/MainTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@
namespace Bref\Test\ConsoleRuntime;

use Bref\Bref;
use Bref\ConsoleRuntime\CommandFailed;
use Bref\ConsoleRuntime\Main;
use Bref\Test\RuntimeTestCase;
use Bref\Test\Server;
use Exception;
use PHPUnit\Framework\TestCase;

class MainTest extends TestCase
class MainTest extends RuntimeTestCase
{
public function setUp(): void
{
parent::setUp();

putenv('LAMBDA_TASK_ROOT=' . __DIR__);
putenv('_HANDLER=console.php');
}

public function test startup hook is called()
{
Bref::beforeStartup(function () {
Expand All @@ -18,4 +28,53 @@ public function test startup hook is called()
$this->expectExceptionMessage('This should be called');
Main::run();
}

public function test happy path()
{
$this->givenAnEvent('');

try {
Main::run();
} catch (\Throwable) {
// Needed because `run()` is an infinite loop and will fail eventually
}

$this->assertInvocationResult([
'exitCode' => 0,
'output' => "Hello world!\n",
]);
}

public function test failure()
{
$this->givenAnEvent('fail');

try {
Main::run();
} catch (\Throwable) {
// Needed because `run()` is an infinite loop and will fail eventually
}

$this->assertInvocationErrorResult(CommandFailed::class, "Hello world!\nFailure\n");
}

public function test trims output to stay under the 6MB limit of Lambda()
{
$this->givenAnEvent('flood');

try {
Main::run();
} catch (\Throwable) {
// Needed because `run()` is an infinite loop and will fail eventually
}

$requests = Server::received();
$this->assertCount(2, $requests);

[, $eventResponse] = $requests;
$this->assertLessThan(6 * 1024 * 1024, strlen($eventResponse->getBody()->__toString()));
// Check the content of the result can be decoded
$result = json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
$this->assertEquals(0, $result['exitCode']);
}
}
14 changes: 14 additions & 0 deletions tests/ConsoleRuntime/console.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php declare(strict_types=1);

if (isset($argv[1]) && $argv[1] === 'flood') {
// Print 7MB of data to go over the 6MB limit
echo str_repeat('x', 7 * 1024 * 1024);
exit(0);
}

echo "Hello world!\n";

if (isset($argv[1]) && $argv[1] === 'fail') {
echo "Failure\n";
exit(1);
}
115 changes: 4 additions & 111 deletions tests/Runtime/LambdaRuntimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@
use Bref\Event\Sqs\SqsHandler;
use Bref\Runtime\LambdaRuntime;
use Bref\Runtime\ResponseTooBig;
use Bref\Test\RuntimeTestCase;
use Bref\Test\Server;
use Exception;
use GuzzleHttp\Psr7\Response;
use JsonException;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
Expand All @@ -30,21 +29,15 @@
*
* The API is mocked using a fake HTTP server.
*/
class LambdaRuntimeTest extends TestCase
class LambdaRuntimeTest extends RuntimeTestCase
{
private LambdaRuntime $runtime;

protected function setUp(): void
{
ob_start();
Server::start();
$this->runtime = new LambdaRuntime('localhost:8126', 'phpunit');
}
parent::setUp();

protected function tearDown(): void
{
Server::stop();
ob_end_clean();
$this->runtime = new LambdaRuntime('localhost:8126', 'phpunit');
}

public function test basic behavior()
Expand Down Expand Up @@ -368,104 +361,4 @@ public function handleEventBridge(EventBridgeEvent $event, Context $context): vo

$this->assertEquals(new EventBridgeEvent($eventData), $handler->event);
}

private function givenAnEvent(mixed $event): void
{
Server::enqueue([
new Response( // lambda event
200,
[
'lambda-runtime-aws-request-id' => '1',
'lambda-runtime-invoked-function-arn' => 'test-function-name',
],
json_encode($event, JSON_THROW_ON_ERROR)
),
new Response(200), // lambda response accepted
]);
}

private function assertInvocationResult(mixed $result)
{
$requests = Server::received();
$this->assertCount(2, $requests);

[$eventRequest, $eventResponse] = $requests;
$this->assertSame('GET', $eventRequest->getMethod());
$this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/next', $eventRequest->getUri()->__toString());
$this->assertSame('POST', $eventResponse->getMethod());
$this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/response', $eventResponse->getUri()->__toString());
$this->assertEquals($result, json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR));
}

private function assertInvocationErrorResult(string $errorClass, string $errorMessage)
{
$requests = Server::received();
$this->assertCount(2, $requests);

[$eventRequest, $eventResponse] = $requests;
$this->assertSame('GET', $eventRequest->getMethod());
$this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/next', $eventRequest->getUri()->__toString());
$this->assertSame('POST', $eventResponse->getMethod());
$this->assertSame('http://localhost:8126/2018-06-01/runtime/invocation/1/error', $eventResponse->getUri()->__toString());

// Check the content of the result of the lambda
$invocationResult = json_decode($eventResponse->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
$this->assertSame([
'errorType',
'errorMessage',
'stackTrace',
], array_keys($invocationResult));
$this->assertEquals($errorClass, $invocationResult['errorType']);
$this->assertEquals($errorMessage, $invocationResult['errorMessage']);
$this->assertIsArray($invocationResult['stackTrace']);
}

private function assertErrorInLogs(string $errorClass, string $errorMessage): void
{
// Decode the logs from stdout
$stdout = $this->getActualOutput();

[$requestId, $message, $json] = explode("\t", $stdout);

$this->assertSame('Invoke Error', $message);

// Check the request ID matches a UUID
$this->assertNotEmpty($requestId);

try {
$invocationResult = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
$this->fail("Could not decode JSON from logs ({$e->getMessage()}): $json");
}
unset($invocationResult['previous']);
$this->assertSame([
'errorType',
'errorMessage',
'stack',
], array_keys($invocationResult));
$this->assertEquals($errorClass, $invocationResult['errorType']);
$this->assertStringContainsString($errorMessage, $invocationResult['errorMessage']);
$this->assertIsArray($invocationResult['stack']);
}

private function assertPreviousErrorsInLogs(array $previousErrors)
{
// Decode the logs from stdout
$stdout = $this->getActualOutput();

[, , $json] = explode("\t", $stdout);

['previous' => $previous] = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$this->assertCount(count($previousErrors), $previous);
foreach ($previous as $index => $error) {
$this->assertSame([
'errorType',
'errorMessage',
'stack',
], array_keys($error));
$this->assertEquals($previousErrors[$index]['errorClass'], $error['errorType']);
$this->assertEquals($previousErrors[$index]['errorMessage'], $error['errorMessage']);
$this->assertIsArray($error['stack']);
}
}
}
Loading

0 comments on commit 5690111

Please sign in to comment.