diff --git a/app/Commands/BaseCommand.php b/app/Commands/BaseCommand.php index 488c7df..bb560a9 100644 --- a/app/Commands/BaseCommand.php +++ b/app/Commands/BaseCommand.php @@ -5,8 +5,10 @@ use App\Commands\Concerns\InteractsWithIO; use App\Repositories\ConfigRepository; use App\Repositories\SpinupWpRepository; +use DeliciousBrains\SpinupWp\Exceptions\ValidationException; use Exception; use GuzzleHttp\Client; +use Illuminate\Support\Str; use LaravelZero\Framework\Commands\Command; abstract class BaseCommand extends Command @@ -21,6 +23,8 @@ abstract class BaseCommand extends Command protected string $command; + protected array $validationLabels = []; + public function __construct(ConfigRepository $configuration, SpinupWpRepository $spinupWp) { parent::__construct(); @@ -55,6 +59,19 @@ public function handle(): int } return $this->action(); + } catch (ValidationException $e) { + $errorRows = []; + foreach ($e->errors()['errors'] as $field => $errors) { + $errorRows[] = [$this->applyValidationLabel($field), implode("\n", $errors)]; + } + + $this->error('Validation errors occurred.'); + $this->stepTable([ + 'Field', + 'Error Message', + ], $errorRows); + + return self::FAILURE; } catch (Exception $e) { $this->error($e->getMessage()); return self::FAILURE; @@ -81,5 +98,14 @@ protected function profile(): string return 'default'; } + protected function applyValidationLabel(string $key): string + { + if (empty($this->validationLabels) || !array_key_exists($key, $this->validationLabels)) { + return Str::title(str_replace('_', ' ', $key)); + } + + return $this->validationLabels[$key]; + } + abstract protected function action(): int; } diff --git a/app/Commands/Concerns/SelectsServer.php b/app/Commands/Concerns/HasServerIdParameter.php similarity index 90% rename from app/Commands/Concerns/SelectsServer.php rename to app/Commands/Concerns/HasServerIdParameter.php index 49b0195..4b97d09 100644 --- a/app/Commands/Concerns/SelectsServer.php +++ b/app/Commands/Concerns/HasServerIdParameter.php @@ -4,13 +4,13 @@ use Illuminate\Support\Collection; -trait SelectsServer +trait HasServerIdParameter { public function selectServer(string $action): Collection { $serverId = $this->argument('server_id'); - if (empty($serverId)) { + if (empty($serverId) && !$this->nonInteractive()) { $serverId = $this->askToSelectServer("Which server would you like to $action"); } diff --git a/app/Commands/Concerns/InteractsWithIO.php b/app/Commands/Concerns/InteractsWithIO.php index 3bcb06d..9dab008 100644 --- a/app/Commands/Concerns/InteractsWithIO.php +++ b/app/Commands/Concerns/InteractsWithIO.php @@ -241,4 +241,9 @@ public function queueResources(Collection $resources, string $endpoint, string $ ucfirst($resourceName), ], $events); } + + protected function nonInteractive(): bool + { + return (bool) $this->option('force'); + } } diff --git a/app/Commands/Servers/DeleteCommand.php b/app/Commands/Servers/DeleteCommand.php index 19074e4..5a167f2 100644 --- a/app/Commands/Servers/DeleteCommand.php +++ b/app/Commands/Servers/DeleteCommand.php @@ -3,11 +3,11 @@ namespace App\Commands\Servers; use App\Commands\BaseCommand; -use App\Commands\Concerns\SelectsServer; +use App\Commands\Concerns\HasServerIdParameter; class DeleteCommand extends BaseCommand { - use SelectsServer; + use HasServerIdParameter; protected $signature = 'servers:delete {server_id? : The server to delete} diff --git a/app/Commands/Servers/RebootCommand.php b/app/Commands/Servers/RebootCommand.php index 7f76b8d..c9c2497 100644 --- a/app/Commands/Servers/RebootCommand.php +++ b/app/Commands/Servers/RebootCommand.php @@ -3,11 +3,11 @@ namespace App\Commands\Servers; use App\Commands\BaseCommand; -use App\Commands\Concerns\SelectsServer; +use App\Commands\Concerns\HasServerIdParameter; class RebootCommand extends BaseCommand { - use SelectsServer; + use HasServerIdParameter; protected $signature = 'servers:reboot {server_id? : The server to reboot} diff --git a/app/Commands/Servers/SshCommand.php b/app/Commands/Servers/SshCommand.php index 3996a81..da462c7 100644 --- a/app/Commands/Servers/SshCommand.php +++ b/app/Commands/Servers/SshCommand.php @@ -3,13 +3,13 @@ namespace App\Commands\Servers; use App\Commands\BaseCommand; +use App\Commands\Concerns\HasServerIdParameter; use App\Commands\Concerns\InteractsWithRemote; -use App\Commands\Concerns\SelectsServer; class SshCommand extends BaseCommand { use InteractsWithRemote; - use SelectsServer; + use HasServerIdParameter; protected $signature = 'servers:ssh {server_id? : The server to connect to} diff --git a/app/Commands/Services/MysqlCommand.php b/app/Commands/Services/MysqlCommand.php index 4b7f13c..0a30d64 100644 --- a/app/Commands/Services/MysqlCommand.php +++ b/app/Commands/Services/MysqlCommand.php @@ -3,11 +3,11 @@ namespace App\Commands\Services; use App\Commands\BaseCommand; -use App\Commands\Concerns\SelectsServer; +use App\Commands\Concerns\HasServerIdParameter; class MysqlCommand extends BaseCommand { - use SelectsServer; + use HasServerIdParameter; protected $signature = 'services:mysql {server_id? : The server to restart MySQL on} diff --git a/app/Commands/Services/NginxCommand.php b/app/Commands/Services/NginxCommand.php index 463ac37..e74ecaf 100644 --- a/app/Commands/Services/NginxCommand.php +++ b/app/Commands/Services/NginxCommand.php @@ -3,11 +3,11 @@ namespace App\Commands\Services; use App\Commands\BaseCommand; -use App\Commands\Concerns\SelectsServer; +use App\Commands\Concerns\HasServerIdParameter; class NginxCommand extends BaseCommand { - use SelectsServer; + use HasServerIdParameter; protected $signature = 'services:nginx {server_id? : The server to restart Nginx on} diff --git a/app/Commands/Services/PhpCommand.php b/app/Commands/Services/PhpCommand.php index f435161..d13fce5 100644 --- a/app/Commands/Services/PhpCommand.php +++ b/app/Commands/Services/PhpCommand.php @@ -3,11 +3,11 @@ namespace App\Commands\Services; use App\Commands\BaseCommand; -use App\Commands\Concerns\SelectsServer; +use App\Commands\Concerns\HasServerIdParameter; class PhpCommand extends BaseCommand { - use SelectsServer; + use HasServerIdParameter; protected $signature = 'services:php {server_id? : The server to restart PHP on} diff --git a/app/Commands/Sites/CreateCommand.php b/app/Commands/Sites/CreateCommand.php new file mode 100644 index 0000000..3a5c825 --- /dev/null +++ b/app/Commands/Sites/CreateCommand.php @@ -0,0 +1,188 @@ + 'Primary Domain', + 'page_cache.enabled' => 'Enable Page Cache', + 'https.enabled' => 'Enable HTTPS', + 'database.name' => 'Database Name', + 'database.username' => 'Database Username', + 'database.password' => 'Database Password', + 'wordpress.title' => 'WordPress Title', + 'wordpress.admin_user' => 'WordPress Admin Username', + 'wordpress.admin_email' => 'WordPress Admin Email', + 'wordpress.admin_password' => 'WordPress Admin Password', + ]; + + protected function action(): int + { + $server = $this->selectServer('deploy to')->first(); + + if (is_null($server)) { + return self::INVALID; + } + + $this->userInput['installation-method'] = Choice::make('What files would you like SpinupWP to install?') + ->withFlag('installation-method') + ->withChoices(OptionsHelper::INSTALLATION_METHODS) + ->withDefault(array_key_first(OptionsHelper::INSTALLATION_METHODS)) + ->nonInteractive($this->nonInteractive()) + ->resolveAnswer($this); + + if (!array_key_exists($this->userInput['installation-method'], OptionsHelper::INSTALLATION_METHODS)) { + $this->error('Invalid installation method.'); + $this->newLine(1); + return self::INVALID; + } + + $this->userInput['domain'] = Ask::make('Primary Domain') + ->withFlag('domain') + ->nonInteractive($this->nonInteractive()) + ->resolveAnswer($this); + + $this->userInput += $this->askQuestions($this->nonInteractive()); + + $this->userInput = array_merge($this->arguments(), $this->options(), $this->userInput); + + $site = $this->spinupwp->createSite($server->id, $this->userInput); + + $this->displaySuccess($site->eventId()); + + return self::SUCCESS; + } + + public function getDomainSlug(): string + { + return str_replace('.', '', $this->userInput['domain']); + } + + public function questions(): array + { + $commonStart = [ + Confirm::make('Enable HTTPS') + ->withFlag('https-enabled') + ->withDefault((bool) !$this->nonInteractive()), + + Ask::make('Site User') + ->withDefault($this->getDomainSlug()), + ]; + + $db = [ + Ask::make('Database Name') + ->withFlag('db-name') + ->withDefault($this->getDomainSlug()), + + Ask::make('Database Username') + ->withFlag('db-user') + ->withDefault($this->getDomainSlug()), + + Ask::make('Database Password') + ->withFlag('db-pass') + ->withDefault(Str::random(12)), + ]; + + $wp = [ + Ask::make('WordPress Title') + ->withFlag('wp-title'), + + Ask::make('WordPress Admin Email') + ->withFlag('wp-admin-email'), + + Ask::make('WordPress Admin Username') + ->withFlag('wp-admin-user'), + + Ask::make('WordPress Admin Password') + ->withFlag('wp-admin-pass') + ->withDefault(Str::random(12)), + ]; + + $commonEnd = [ + Choice::make('PHP Version') + ->withFlag('php-version') + ->withChoices(OptionsHelper::PHP_VERSIONS) + ->withDefault('8.0'), + + Confirm::make('Enable Page Cache') + ->withFlag('page-cache-enabled') + ->withDefault((bool) !$this->nonInteractive()), + ]; + + switch ($this->userInput['installation-method']) { + case 'blank': + return array_merge($commonStart, $commonEnd); + default: + return array_merge( + $commonStart, + $db, + $wp, + $commonEnd + ); + } + } + + protected function displaySuccess($eventId): void + { + $tableHeadings = [ + 'Event ID', + 'Site', + ]; + + $tableRow = [ + $eventId, + $this->userInput['domain'], + ]; + + if ($this->userInput['installation-method'] === 'wp') { + $tableHeadings = array_merge($tableHeadings, [ + 'Database Password', + 'WordPress Admin Password', + ]); + + $tableRow = array_merge($tableRow, [ + $this->userInput['db-pass'], + $this->userInput['wp-admin-pass'], + ]); + } + + $this->successfulStep('Site queued for creation.'); + + $this->stepTable($tableHeadings, [$tableRow]); + } +} diff --git a/app/Helpers/OptionsHelper.php b/app/Helpers/OptionsHelper.php new file mode 100644 index 0000000..58eb91b --- /dev/null +++ b/app/Helpers/OptionsHelper.php @@ -0,0 +1,14 @@ + 'WordPress', + 'blank' => 'Don\'t Install Any Files', + ]; + + public const PHP_VERSIONS = ['8.0', '7.4']; +} diff --git a/app/Questions/Ask.php b/app/Questions/Ask.php new file mode 100644 index 0000000..9a37854 --- /dev/null +++ b/app/Questions/Ask.php @@ -0,0 +1,11 @@ +command->ask($this->prompt, $this->default); + } +} diff --git a/app/Questions/Choice.php b/app/Questions/Choice.php new file mode 100644 index 0000000..c59b008 --- /dev/null +++ b/app/Questions/Choice.php @@ -0,0 +1,27 @@ + */ + private array $choices = []; + + /** + * @param array $choices + * @return static + */ + public function withChoices(array $choices): self + { + $this->choices = $choices; + return $this; + } + + /** @return string|array */ + public function question() + { + return $this->command->choice($this->prompt, $this->choices, $this->default); + } +} diff --git a/app/Questions/Confirm.php b/app/Questions/Confirm.php new file mode 100644 index 0000000..3bcad80 --- /dev/null +++ b/app/Questions/Confirm.php @@ -0,0 +1,11 @@ +command->confirm($this->prompt, $this->default); + } +} diff --git a/app/Questions/HasQuestions.php b/app/Questions/HasQuestions.php new file mode 100644 index 0000000..5507026 --- /dev/null +++ b/app/Questions/HasQuestions.php @@ -0,0 +1,25 @@ + */ + public function questions(): array + { + return []; + } + + public function askQuestions(bool $nonInteractive = false): array + { + $answers = []; + foreach ($this->questions() as $question) { + $question->nonInteractive($nonInteractive); + $answers += [ + $question->flag => $question->resolveAnswer($this), + ]; + } + + return $answers; + } +} diff --git a/app/Questions/Question.php b/app/Questions/Question.php new file mode 100644 index 0000000..267a50a --- /dev/null +++ b/app/Questions/Question.php @@ -0,0 +1,78 @@ +prompt = $prompt; + $this->flag = Str::kebab($prompt); + } + + /** @return static */ + public static function make(string $prompt): self + { + return new static($prompt); + } + + /** + * @param mixed $default + * @return static + */ + public function withDefault($default): self + { + $this->default = $default; + + return $this; + } + + /** @return static */ + public function withFlag(string $flag): self + { + $this->flag = $flag; + return $this; + } + + /** @return static */ + public function nonInteractive(bool $nonInteractive = true): self + { + $this->nonInteractive = $nonInteractive; + + return $this; + } + + /** @return mixed */ + public function resolveAnswer(Command $command) + { + $this->command = $command; + + $flagInput = $this->command->option($this->flag); + + if ($flagInput || $this->nonInteractive) { + return $flagInput ?? $this->default ?? null; + } + + return $this->question(); + } + + /** @return mixed */ + abstract protected function question(); +} diff --git a/app/Questions/WithChoices.php b/app/Questions/WithChoices.php new file mode 100644 index 0000000..2f1913e --- /dev/null +++ b/app/Questions/WithChoices.php @@ -0,0 +1,8 @@ +spinupwp->sites->listForServer($serverId, 1, $params)); } + + public function createSite(int $serverId, array $inputParams): SiteResource + { + $inputParams = [ + 'installation_method' => $inputParams['installation-method'], + 'domain' => $inputParams['domain'], + 'php_version' => $inputParams['php-version'], + 'site_user' => $inputParams['site-user'], + 'page_cache' => [ + 'enabled' => $inputParams['page-cache-enabled'], + ], + 'https' => [ + 'enabled' => $inputParams['https-enabled'], + ], + 'database' => [ + 'name' => $inputParams['db-name'], + 'username' => $inputParams['db-user'], + 'password' => $inputParams['db-pass'], + ], + 'wordpress' => [ + 'title' => $inputParams['wp-title'], + 'admin_user' => $inputParams['wp-admin-user'], + 'admin_email' => $inputParams['wp-admin-email'], + 'admin_password' => $inputParams['wp-admin-pass'], + ], + ]; + + return $this->spinupwp->sites->create($serverId, $inputParams); + } } diff --git a/tests/Feature/Commands/SitesCreateCommandTest.php b/tests/Feature/Commands/SitesCreateCommandTest.php new file mode 100644 index 0000000..5476037 --- /dev/null +++ b/tests/Feature/Commands/SitesCreateCommandTest.php @@ -0,0 +1,166 @@ +inputParamDefaults = [ + 'installation_method' => null, + 'domain' => null, + 'php_version' => '8.0', + 'site_user' => null, + 'page_cache' => [ + 'enabled' => 0, + ], + 'https' => [ + 'enabled' => 0, + ], + 'database' => [ + 'name' => null, + 'username' => null, + 'password' => null, + ], + 'wordpress' => [ + 'title' => null, + 'admin_user' => null, + 'admin_email' => null, + 'admin_password' => null, + ], + ]; + + $this->clientMock->shouldReceive('request')->with('GET', 'servers/1', [])->andReturn( + new Response(200, [], json_encode(['data' => ['id' => 1, 'name' => 'hellfish-media']])) + ); +}); + +test('"sites:create blank" fails with invalid data', function () { + $params = array_merge($this->inputParamDefaults, [ + 'installation_method' => 'blank', + 'server_id' => 1, + ]); + + $this->clientMock->shouldReceive('request')->with('POST', 'sites', [ + 'form_params' => $params, + ])->andReturn( + new Response(422, [], json_encode([ + 'message' => 'The given data was invalid.', + 'errors' => [ + ['field' => 'error message'], + ], + ])) + ); + + $this->artisan('sites:create 1 --installation-method=blank -f') + ->expectsOutput('Validation errors occurred.') + ->assertExitCode(1); +}); + +test('"sites:create blank" succeeds with correct params', function () { + $params = array_merge($this->inputParamDefaults, [ + 'installation_method' => 'blank', + 'domain' => 'hellfish.media', + 'server_id' => 1, + 'php_version' => '7.4', + + 'site_user' => 'hellfishmedia', + 'https' => ['enabled' => true], + 'page_cache' => ['enabled' => true], + ]); + + $this->clientMock->shouldReceive('request')->with('POST', 'sites', [ + 'form_params' => $params, + ])->andReturn(new Response(200, [], json_encode([ + 'event_id' => '100', + 'data' => [ + 'id' => 1, + 'domain' => 'hellfish.media', + 'status' => 'deploying', + ], + ]))); + + $this->artisan('sites:create 1 --installation-method=blank --domain=hellfish.media --https-enabled --page-cache-enabled --php-version="7.4" -f') + ->assertExitCode(0); +}); + +test('"sites:create wp" fails with invalid data', function () { + $params = array_merge($this->inputParamDefaults, [ + 'installation_method' => 'wp', + 'server_id' => 1, + 'site_user' => '', + 'database' => [ + 'name' => '', + 'username' => '', + 'password' => 'password', + ], + 'wordpress' => [ + 'title' => null, + 'admin_user' => null, + 'admin_email' => null, + 'admin_password' => 'password', + ], + ]); + + $this->clientMock->shouldReceive('request')->with('POST', 'sites', [ + 'form_params' => $params, + ])->andReturn( + new Response(422, [], json_encode([ + 'message' => 'The given data was invalid.', + 'errors' => [ + ['field' => 'error message'], + ], + ])) + ); + + $this->artisan('sites:create 1 --installation-method=wp --db-pass=password --wp-admin-pass=password -f') + ->expectsOutput('Validation errors occurred.') + ->assertExitCode(1); +}); + +test('"sites:create wp" succeeds with correct data', function () { + $params = array_merge($this->inputParamDefaults, [ + 'installation_method' => 'wp', + 'server_id' => 1, + 'domain' => 'hellfish.media', + 'site_user' => 'test', + 'https' => ['enabled' => true], + 'database' => [ + 'name' => 'dbname', + 'username' => 'dbuser', + 'password' => 'password', + ], + 'wordpress' => [ + 'title' => 'Site Title', + 'admin_user' => 'abe', + 'admin_email' => 'flying@hellfish.media', + 'admin_password' => 'password', + ], + ]); + + $this->clientMock->shouldReceive('request')->with('POST', 'sites', [ + 'form_params' => $params, + ])->andReturn( + new Response(200, [], json_encode([ + 'event_id' => '100', + 'data' => [ + 'id' => 1, + 'domain' => 'hellfish.media', + 'status' => 'deploying', + ], + ])) + ); + + $this->artisan('sites:create 1 + --installation-method=wp + --domain="hellfish.media" + --site-user=test + --https-enabled + --db-name=dbname + --db-user=dbuser + --db-pass=password + --wp-title="Site Title" + --wp-admin-user="abe" + --wp-admin-email="flying@hellfish.media" + --wp-admin-pass=password -f') + ->assertExitCode(0); +});