diff --git a/bref b/bref index a5e83f209..0fa00f06d 100755 --- a/bref +++ b/bref @@ -2,15 +2,12 @@ command('init', function (SymfonyStyle $io) { - $exeFinder = new ExecutableFinder(); - if (! $exeFinder->find('serverless')) { - $io->error( - 'The `serverless` command is not installed.' . PHP_EOL . - 'Please follow the instructions at https://bref.sh/docs/installation.html' - ); - - return 1; - } - - if (file_exists('serverless.yml') || file_exists('index.php')) { - $io->error('The directory already contains a `serverless.yml` and/or `index.php` file.'); - - return 1; - } - - $choice = $io->choice( - 'What kind of lambda do you want to create? (you will be able to add more functions later by editing `serverless.yml`)', - [ - 'Web application', - 'Event-driven function', - ], - 'Web application', - ); - $templateDirectory = [ - 'Web application' => 'http', - 'Event-driven function' => 'function', - ][$choice]; - - $fs = new Filesystem; - $rootPath = __DIR__ . "/template/$templateDirectory"; - - $io->writeln("Creating index.php"); - $fs->copy("$rootPath/index.php", 'index.php'); - - $io->writeln("Creating serverless.yml"); - - $template = file_get_contents("$rootPath/serverless.yml"); - - $template = str_replace('PHP_VERSION', PHP_MAJOR_VERSION . PHP_MINOR_VERSION, $template); - - file_put_contents('serverless.yml', $template); - - $filesToGitAdd = ['index.php', 'serverless.yml']; - - /* - * We check if this is a git repository to automatically add files to git. - */ - if ((new Process(['git', 'rev-parse', '--is-inside-work-tree']))->run() === 0) { - foreach ($filesToGitAdd as $file) { - (new Process(['git', 'add', $file]))->run(); - } - $io->success([ - 'Project initialized and ready to test or deploy.', - 'The files created were automatically added to git.', - ]); - } else { - $io->success('Project initialized and ready to test or deploy.'); - } - - return 0; -}); - -/** - * Run a CLI command in the remote environment. - */ -$app->command('cli function [--region=] [--profile=] [arguments]*', function (string $function, ?string $region, ?string $profile, array $arguments, SymfonyStyle $io) { - $lambda = new SimpleLambdaClient( - $region ?: getenv('AWS_DEFAULT_REGION') ?: 'us-east-1', - $profile ?: getenv('AWS_PROFILE') ?: 'default', - 15 * 60 // maximum duration on Lambda - ); - - // Because arguments may contain spaces, and are going to be executed remotely - // as a separate process, we need to escape all arguments. - $arguments = array_map(static function (string $arg): string { - return escapeshellarg($arg); - }, $arguments); - - try { - $result = $lambda->invoke($function, json_encode(implode(' ', $arguments))); - } catch (InvocationFailed $e) { - $io->getErrorStyle()->writeln('' . $e->getInvocationLogs() . ''); - $io->error($e->getMessage()); - return 1; - } - - $payload = $result->getPayload(); - if (isset($payload['output'])) { - $io->writeln($payload['output']); - } else { - $io->error('The command did not return a valid response.'); - $io->writeln('Logs:'); - $io->write('' . $result->getLogs() . ''); - $io->writeln('Lambda result payload:'); - $io->writeln(json_encode($payload, JSON_PRETTY_PRINT)); - return 1; - } - - return (int) ($payload['exitCode'] ?? 1); -}); - -/** - * Invoke a function locally - */ -$app->command(Local::SIGNATURE, new Local); - -$app->command('dashboard [--host=] [--port=] [--profile=] [--stage=]', function (SymfonyStyle $io, string $host = 'localhost', int $port = 8000, string $profile = null, string $stage = null) { - $io->info('The Bref Dashboard is also available as an application: https://dashboard.bref.sh'); - if ($host === 'localhost') { - $host = '127.0.0.1'; - } - if ($profile === null) { - $profile = getenv('AWS_PROFILE') ?: 'default'; - } - - if (! file_exists('serverless.yml')) { - $io->error('No `serverless.yml` file found.'); - - return 1; - } - - $exeFinder = new ExecutableFinder(); - if (! $exeFinder->find('docker')) { - $io->error( - 'The `docker` command is not installed.' . PHP_EOL . - 'Please follow the instructions at https://docs.docker.com/install/' - ); - - return 1; - } - - if (! $exeFinder->find('serverless')) { - $io->error( - 'The `serverless` command is not installed.' . PHP_EOL . - 'Please follow the instructions at https://bref.sh/docs/installation.html' - ); - - return 1; - } - - $serverlessInfo = new Process(['serverless', 'info', '--stage', $stage, '--aws-profile', $profile]); - $serverlessInfo->start(); - $animation = new LoadingAnimation($io); - do { - $animation->tick('Retrieving the stack'); - usleep(100*1000); - } while ($serverlessInfo->isRunning()); - $animation->clear(); - - if (!$serverlessInfo->isSuccessful()) { - $io->error('The command `serverless info` failed' . PHP_EOL . $serverlessInfo->getOutput()); - - return 1; - } - - $serverlessInfoOutput = $serverlessInfo->getOutput(); - - $region = []; - preg_match('/region: ([a-z0-9-]*)/', $serverlessInfoOutput, $region); - $region = $region[1]; - - $stack = []; - preg_match('/stack: ([a-zA-Z0-9-]*)/', $serverlessInfoOutput, $stack); - $stack = $stack[1]; - - $io->writeln("Stack: $stack ($region)"); - - $dockerPull = new Process(['docker', 'pull', 'bref/dashboard']); - $dockerPull->setTimeout(null); - $dockerPull->start(); - do { - $animation->tick('Retrieving the latest version of the dashboard'); - usleep(100*1000); - } while ($dockerPull->isRunning()); - $animation->clear(); - if (! $dockerPull->isSuccessful()) { - $io->error([ - 'The command `docker pull bref/dashboard` failed', - $dockerPull->getErrorOutput(), - ]); - - return 1; - } - - $process = new Process(['docker', 'run', '--rm', '-p', $host . ':' . $port.':8000', '-v', getenv('HOME').'/.aws:/root/.aws:ro', '--env', 'STACKNAME='.$stack, '--env', 'REGION='.$region, '--env', 'AWS_PROFILE='.$profile, 'bref/dashboard']); - $process->setTimeout(null); - $process->start(); - do { - $animation->tick('Starting the dashboard'); - usleep(100*1000); - $serverOutput = $process->getOutput() . $process->getErrorOutput(); - $hasStarted = (strpos($serverOutput, 'Development Server') !== false); - } while ($process->isRunning() && !$hasStarted); - $animation->clear(); - if (!$process->isRunning()) { - $io->error([ - 'The dashboard failed to start', - $process->getErrorOutput(), - ]); - - return 1; - } - $url = "http://$host:$port"; - $io->writeln("Dashboard started: $url"); - OpenUrl::open($url); - $process->wait(function ($type, $buffer) { - if (Process::ERR === $type) { - echo 'ERR > '.$buffer; - } else { - echo 'OUT > '.$buffer; - } - }); - - return $process->getExitCode(); -})->descriptions('Start the dashboard'); - -$app->command('layers region', function (string $region, SymfonyStyle $io) { - $layers = json_decode(file_get_contents(__DIR__ . '/layers.json'), true); - $io->title("Layers for the $region region"); - - $array = []; - foreach ($layers as $layer => $versions) { - $version = $versions[$region]; - $array[] = [ - $layer, - $version, - "arn:aws:lambda:$region:416566615250:layer:$layer:$version", - ]; - } - $io->table([ - 'Layer', - 'Version', - 'ARN', - ], $array); - - return 0; -})->descriptions('Displays the versions of the Bref layers'); +$app = new Application('Deploy serverless PHP applications'); +$app->add(new Init()); +$app->add(new Cli()); +$app->add(new Local()); +$app->add(new Dashboard()); +$app->add(new Layers()); $app->run(); diff --git a/composer.json b/composer.json index f962caba2..ed9d41d15 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "php": ">=7.3.0", "ext-curl": "*", "ext-json": "*", - "mnapoli/silly": "^1.7", + "symfony/console": "^3.0|^4.0|^5.0|^6.0", "symfony/filesystem": "^3.1|^4.0|^5.0|^6.0", "symfony/process": "^4.2|^5.0|^6.0", "psr/http-message": "^1.0", diff --git a/demo/console.php b/demo/console.php index b723076ea..a4723ca0e 100644 --- a/demo/console.php +++ b/demo/console.php @@ -1,27 +1,45 @@ command('hello [name]', function (string $name = 'World!', OutputInterface $output) { - $output->writeln('Hello ' . $name); -}); -$silly->command('phpinfo', function (OutputInterface $output) { - ob_start(); - phpinfo(); - $phpinfo = ob_get_clean(); - $output->write($phpinfo); -}); -$silly->command('error', function (OutputInterface $output) { - $output->writeln('There was an error!'); - return 1; -}); -$silly->command('sleep', function () { - sleep(120); -}); - -$silly->run(); +$app = new Application(); + +$app->register('hello') + ->addArgument('name', InputArgument::OPTIONAL, '', 'World!') + ->setCode(function (InputInterface $input, OutputInterface $output): int { + $output->writeln('Hello, ' . $input->getArgument('name')); + return Command::SUCCESS; + }) +; + +$app->register('phpinfo') + ->setCode(function (InputInterface $input, OutputInterface $output): int { + ob_start(); + phpinfo(); + $phpinfo = ob_get_clean(); + $output->write($phpinfo); + return Command::SUCCESS; + }) +; + +$app->register('error') + ->setCode(function (InputInterface $input, OutputInterface $output): int { + $output->writeln('There was an error!'); + return Command::FAILURE; + }) +; + +$app->register('sleep') + ->setCode(function (InputInterface $input, OutputInterface $output): int { + sleep(120); + return Command::SUCCESS; + }) +; + +$app->run(); diff --git a/src/Console/Command/Cli.php b/src/Console/Command/Cli.php new file mode 100644 index 000000000..77dcbdf03 --- /dev/null +++ b/src/Console/Command/Cli.php @@ -0,0 +1,68 @@ +setName('cli') + ->setDescription('Runs a CLI command in the remote environment') + ->addArgument('function', InputArgument::REQUIRED) + ->addArgument('arguments', InputArgument::OPTIONAL) + ->addOption('region', 'r') + ->addOption('profile', 'p'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $function = $input->getArgument('function'); + $arguments = $input->getArgument('arguments'); + $region = $input->getOption('region'); + $profile = $input->getOption('profile'); + + $lambda = new SimpleLambdaClient( + $region ?: getenv('AWS_DEFAULT_REGION') ?: 'us-east-1', + $profile ?: getenv('AWS_PROFILE') ?: 'default', + 15 * 60 // maximum duration on Lambda + ); + + // Because arguments may contain spaces, and are going to be executed remotely + // as a separate process, we need to escape all arguments. + $arguments = array_map(static function (string $arg): string { + return escapeshellarg($arg); + }, $arguments); + + try { + $result = $lambda->invoke($function, json_encode(implode(' ', $arguments))); + } catch (InvocationFailed $e) { + $io->getErrorStyle()->writeln('' . $e->getInvocationLogs() . ''); + $io->error($e->getMessage()); + return Command::FAILURE; + } + + $payload = $result->getPayload(); + if (isset($payload['output'])) { + $io->writeln($payload['output']); + } else { + $io->error('The command did not return a valid response.'); + $io->writeln('Logs:'); + $io->write('' . $result->getLogs() . ''); + $io->writeln('Lambda result payload:'); + $io->writeln(json_encode($payload, JSON_PRETTY_PRINT)); + return Command::FAILURE; + } + + return (int) ($payload['exitCode'] ?? Command::FAILURE); + } +} diff --git a/src/Console/Command/Dashboard.php b/src/Console/Command/Dashboard.php new file mode 100644 index 000000000..1f8bec496 --- /dev/null +++ b/src/Console/Command/Dashboard.php @@ -0,0 +1,144 @@ +setName('dashboard') + ->setDescription('Starts the dashboard') + ->addOption('host', null, InputOption::VALUE_REQUIRED, '', 'localhost') + ->addOption('port', null, InputOption::VALUE_REQUIRED, '', '8000') + ->addOption('profile', null, InputOption::VALUE_REQUIRED) + ->addOption('stage', null, InputOption::VALUE_REQUIRED); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $host = $input->getOption('host'); + $port = (int) $input->getOption('port'); + $profile = $input->getOption('profile'); + $stage = $input->getOption('stage'); + + $io->info('The Bref Dashboard is also available as an application: https://dashboard.bref.sh'); + if ($host === 'localhost') { + $host = '127.0.0.1'; + } + if ($profile === null) { + $profile = getenv('AWS_PROFILE') ?: 'default'; + } + + if (! file_exists('serverless.yml')) { + $io->error('No `serverless.yml` file found.'); + + return Command::FAILURE; + } + + $exeFinder = new ExecutableFinder; + if (! $exeFinder->find('docker')) { + $io->error( + 'The `docker` command is not installed.' . PHP_EOL . + 'Please follow the instructions at https://docs.docker.com/install/' + ); + + return Command::FAILURE; + } + + if (! $exeFinder->find('serverless')) { + $io->error( + 'The `serverless` command is not installed.' . PHP_EOL . + 'Please follow the instructions at https://bref.sh/docs/installation.html' + ); + + return Command::FAILURE; + } + + $serverlessInfo = new Process(['serverless', 'info', '--stage', $stage, '--aws-profile', $profile]); + $serverlessInfo->start(); + $animation = new LoadingAnimation($io); + do { + $animation->tick('Retrieving the stack'); + usleep(100 * 1000); + } while ($serverlessInfo->isRunning()); + $animation->clear(); + + if (! $serverlessInfo->isSuccessful()) { + $io->error('The command `serverless info` failed' . PHP_EOL . $serverlessInfo->getOutput()); + + return Command::FAILURE; + } + + $serverlessInfoOutput = $serverlessInfo->getOutput(); + + $region = []; + preg_match('/region: ([a-z0-9-]*)/', $serverlessInfoOutput, $region); + $region = $region[1]; + + $stack = []; + preg_match('/stack: ([a-zA-Z0-9-]*)/', $serverlessInfoOutput, $stack); + $stack = $stack[1]; + + $io->writeln("Stack: $stack ($region)"); + + $dockerPull = new Process(['docker', 'pull', 'bref/dashboard']); + $dockerPull->setTimeout(null); + $dockerPull->start(); + do { + $animation->tick('Retrieving the latest version of the dashboard'); + usleep(100 * 1000); + } while ($dockerPull->isRunning()); + $animation->clear(); + if (! $dockerPull->isSuccessful()) { + $io->error([ + 'The command `docker pull bref/dashboard` failed', + $dockerPull->getErrorOutput(), + ]); + + return Command::FAILURE; + } + + $process = new Process(['docker', 'run', '--rm', '-p', $host . ':' . $port . ':8000', '-v', getenv('HOME') . '/.aws:/root/.aws:ro', '--env', 'STACKNAME=' . $stack, '--env', 'REGION=' . $region, '--env', 'AWS_PROFILE=' . $profile, 'bref/dashboard']); + $process->setTimeout(null); + $process->start(); + do { + $animation->tick('Starting the dashboard'); + usleep(100 * 1000); + $serverOutput = $process->getOutput() . $process->getErrorOutput(); + $hasStarted = (strpos($serverOutput, 'Development Server') !== false); + } while ($process->isRunning() && ! $hasStarted); + $animation->clear(); + if (! $process->isRunning()) { + $io->error([ + 'The dashboard failed to start', + $process->getErrorOutput(), + ]); + + return Command::FAILURE; + } + $url = "http://$host:$port"; + $io->writeln("Dashboard started: $url"); + OpenUrl::open($url); + $process->wait(function ($type, $buffer): void { + if ($type === Process::ERR) { + echo 'ERR > ' . $buffer; + } else { + echo 'OUT > ' . $buffer; + } + }); + + return $process->getExitCode(); + } +} diff --git a/src/Console/Command/Init.php b/src/Console/Command/Init.php new file mode 100644 index 000000000..d55f7a39b --- /dev/null +++ b/src/Console/Command/Init.php @@ -0,0 +1,85 @@ +setName('init'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $exeFinder = new ExecutableFinder; + if (! $exeFinder->find('serverless')) { + $io->error( + 'The `serverless` command is not installed.' . PHP_EOL . + 'Please follow the instructions at https://bref.sh/docs/installation.html' + ); + + return Command::FAILURE; + } + + if (file_exists('serverless.yml') || file_exists('index.php')) { + $io->error('The directory already contains a `serverless.yml` and/or `index.php` file.'); + + return Command::FAILURE; + } + + $choice = $io->choice( + 'What kind of lambda do you want to create? (you will be able to add more functions later by editing `serverless.yml`)', + [ + 'Web application', + 'Event-driven function', + ], + 'Web application', + ); + $templateDirectory = [ + 'Web application' => 'http', + 'Event-driven function' => 'function', + ][$choice]; + + $fs = new Filesystem; + $rootPath = dirname(__DIR__, 3) . "/template/$templateDirectory"; + + $io->writeln('Creating index.php'); + $fs->copy("$rootPath/index.php", 'index.php'); + + $io->writeln('Creating serverless.yml'); + + $template = file_get_contents("$rootPath/serverless.yml"); + + $template = str_replace('PHP_VERSION', PHP_MAJOR_VERSION . PHP_MINOR_VERSION, $template); + + file_put_contents('serverless.yml', $template); + + $filesToGitAdd = ['index.php', 'serverless.yml']; + + /* + * We check if this is a git repository to automatically add files to git. + */ + if ((new Process(['git', 'rev-parse', '--is-inside-work-tree']))->run() === 0) { + foreach ($filesToGitAdd as $file) { + (new Process(['git', 'add', $file]))->run(); + } + $io->success([ + 'Project initialized and ready to test or deploy.', + 'The files created were automatically added to git.', + ]); + } else { + $io->success('Project initialized and ready to test or deploy.'); + } + + return Command::SUCCESS; + } +} diff --git a/src/Console/Command/Layers.php b/src/Console/Command/Layers.php new file mode 100644 index 000000000..465e187a0 --- /dev/null +++ b/src/Console/Command/Layers.php @@ -0,0 +1,45 @@ +setName('layers') + ->setDescription('Displays the versions of the Bref layers') + ->addArgument('region'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $region = $input->getArgument('region'); + + $layers = json_decode(file_get_contents(dirname(__DIR__, 3) . '/layers.json'), true); + $io->title("Layers for the $region region"); + + $array = []; + foreach ($layers as $layer => $versions) { + $version = $versions[$region]; + $array[] = [ + $layer, + $version, + "arn:aws:lambda:$region:416566615250:layer:$layer:$version", + ]; + } + $io->table([ + 'Layer', + 'Version', + 'ARN', + ], $array); + + return Command::SUCCESS; + } +} diff --git a/src/Console/Command/Local.php b/src/Console/Command/Local.php index 3bef1085b..3b6397955 100644 --- a/src/Console/Command/Local.php +++ b/src/Console/Command/Local.php @@ -8,6 +8,11 @@ use Exception; use JsonException; use Psr\Container\NotFoundExceptionInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Yaml\Yaml; use Throwable; @@ -15,12 +20,28 @@ /** * Local function invocation. */ -class Local +class Local extends Command { - public const SIGNATURE = 'local [function] [data] [--file=] [--handler=] [--config=]'; + protected function configure(): void + { + $this + ->setName('local') + ->addArgument('function', InputArgument::OPTIONAL) + ->addArgument('data', InputArgument::OPTIONAL) + ->addOption('file', 'f', InputOption::VALUE_REQUIRED) + ->addOption('handler', null, InputOption::VALUE_REQUIRED) + ->addOption('config', 'c', InputOption::VALUE_REQUIRED); + } - public function __invoke(?string $function, ?string $data, ?string $file, ?string $handler, ?string $config, SymfonyStyle $io): int + protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); + $function = $input->getArgument('function'); + $data = $input->getArgument('data'); + $file = $input->getOption('file'); + $handler = $input->getOption('handler'); + $config = $input->getOption('config'); + if ($function === null && $handler === null) { throw new Exception('Please provide a function name or the --handler= option.'); } @@ -79,14 +100,14 @@ public function __invoke(?string $function, ?string $data, ?string $file, ?strin $e->getTraceAsString(), ]); $io->error($e->getMessage()); - return 1; + return Command::FAILURE; } $this->logEnd($startTime, $io, $requestId); // Show the invocation result $io->block(json_encode($result, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), null, 'fg=black;bg=green', '', true); - return 0; + return Command::SUCCESS; } private function handlerFromServerlessYml(string $function, ?string $config): string