diff --git a/src/Build/BuildContainerImageStep.php b/src/Build/BuildContainerImageStep.php index a790a93..4555378 100644 --- a/src/Build/BuildContainerImageStep.php +++ b/src/Build/BuildContainerImageStep.php @@ -15,9 +15,9 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\Exception\RuntimeException; +use Ymir\Cli\Executable\DockerExecutable; use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; use Ymir\Cli\Support\Arr; -use Ymir\Cli\Tool\Docker; class BuildContainerImageStep implements BuildStepInterface { @@ -28,6 +28,13 @@ class BuildContainerImageStep implements BuildStepInterface */ private $buildDirectory; + /** + * The Docker executable. + * + * @var DockerExecutable + */ + private $dockerExecutable; + /** * The file system. * @@ -38,9 +45,10 @@ class BuildContainerImageStep implements BuildStepInterface /** * Constructor. */ - public function __construct(string $buildDirectory, Filesystem $filesystem) + public function __construct(string $buildDirectory, DockerExecutable $dockerExecutable, Filesystem $filesystem) { $this->buildDirectory = rtrim($buildDirectory, '/'); + $this->dockerExecutable = $dockerExecutable; $this->filesystem = $filesystem; } @@ -75,6 +83,6 @@ public function perform(string $environment, ProjectConfiguration $projectConfig throw new RuntimeException('Unable to find a "Dockerfile" to build the container image'); } - Docker::build($dockerfileName, sprintf('%s:%s', $projectConfiguration->getProjectName(), $environment), $this->buildDirectory); + $this->dockerExecutable->build($dockerfileName, sprintf('%s:%s', $projectConfiguration->getProjectName(), $environment), $this->buildDirectory); } } diff --git a/src/Command/Cache/CacheTunnelCommand.php b/src/Command/Cache/CacheTunnelCommand.php index 8eb0d30..09045b2 100644 --- a/src/Command/Cache/CacheTunnelCommand.php +++ b/src/Command/Cache/CacheTunnelCommand.php @@ -16,9 +16,12 @@ use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; +use Ymir\Cli\ApiClient; +use Ymir\Cli\CliConfiguration; use Ymir\Cli\Command\Network\AddBastionHostCommand; +use Ymir\Cli\Executable\SshExecutable; +use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; use Ymir\Cli\Support\Arr; -use Ymir\Cli\Tool\Ssh; class CacheTunnelCommand extends AbstractCacheCommand { @@ -29,6 +32,23 @@ class CacheTunnelCommand extends AbstractCacheCommand */ public const NAME = 'cache:tunnel'; + /** + * The SSH executable. + * + * @var SshExecutable + */ + protected $sshExecutable; + + /** + * Constructor. + */ + public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, ProjectConfiguration $projectConfiguration, SshExecutable $sshExecutable) + { + parent::__construct($apiClient, $cliConfiguration, $projectConfiguration); + + $this->sshExecutable = $sshExecutable; + } + /** * {@inheritdoc} */ @@ -66,7 +86,7 @@ protected function perform() $this->output->info(sprintf('Creating SSH tunnel to the "%s" cache cluster. You can connect using: localhost:%s', $cache['name'], $localPort)); - $tunnel = Ssh::tunnelBastionHost($network->get('bastion_host'), $localPort, $cache['endpoint'], 6379); + $tunnel = $this->sshExecutable->openTunnelToBastionHost($network->get('bastion_host'), $localPort, $cache['endpoint'], 6379); $this->output->info('Once finished, press "Ctrl+C" to close the tunnel'); diff --git a/src/Command/Database/AbstractDatabaseCommand.php b/src/Command/Database/AbstractDatabaseCommand.php index ea3e83c..bc65923 100644 --- a/src/Command/Database/AbstractDatabaseCommand.php +++ b/src/Command/Database/AbstractDatabaseCommand.php @@ -15,15 +15,35 @@ use Carbon\Carbon; use Symfony\Component\Console\Exception\RuntimeException; +use Ymir\Cli\ApiClient; +use Ymir\Cli\CliConfiguration; use Ymir\Cli\Command\AbstractCommand; use Ymir\Cli\Command\Network\AddBastionHostCommand; use Ymir\Cli\Exception\InvalidInputException; +use Ymir\Cli\Executable\SshExecutable; use Ymir\Cli\Process\Process; +use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; use Ymir\Cli\Support\Arr; -use Ymir\Cli\Tool\Ssh; abstract class AbstractDatabaseCommand extends AbstractCommand { + /** + * The SSH executable. + * + * @var SshExecutable + */ + protected $sshExecutable; + + /** + * Constructor. + */ + public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, ProjectConfiguration $projectConfiguration, SshExecutable $sshExecutable) + { + parent::__construct($apiClient, $cliConfiguration, $projectConfiguration); + + $this->sshExecutable = $sshExecutable; + } + /** * Determine the database server that the command is interacting with. */ @@ -81,9 +101,9 @@ protected function determineUser(): string } /** - * Start a SSH tunnel to a private database server. + * Open a SSH tunnel to a private database server. */ - protected function startSshTunnel(array $databaseServer): Process + protected function openSshTunnel(array $databaseServer): Process { $this->output->info(sprintf('Opening SSH tunnel to "%s" private database server', $databaseServer['name'])); @@ -94,7 +114,7 @@ protected function startSshTunnel(array $databaseServer): Process } $bastionHost = $network->get('bastion_host'); - $tunnel = Ssh::tunnelBastionHost($bastionHost, 3305, $databaseServer['endpoint'], 3306); + $tunnel = $this->sshExecutable->openTunnelToBastionHost($bastionHost, 3305, $databaseServer['endpoint'], 3306); $timeout = Carbon::now()->addSeconds(10); diff --git a/src/Command/Database/DatabaseServerTunnelCommand.php b/src/Command/Database/DatabaseServerTunnelCommand.php index 14f853a..be444cd 100644 --- a/src/Command/Database/DatabaseServerTunnelCommand.php +++ b/src/Command/Database/DatabaseServerTunnelCommand.php @@ -16,9 +16,12 @@ use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; +use Ymir\Cli\ApiClient; +use Ymir\Cli\CliConfiguration; use Ymir\Cli\Command\Network\AddBastionHostCommand; +use Ymir\Cli\Executable\SshExecutable; +use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; use Ymir\Cli\Support\Arr; -use Ymir\Cli\Tool\Ssh; class DatabaseServerTunnelCommand extends AbstractDatabaseServerCommand { @@ -29,6 +32,23 @@ class DatabaseServerTunnelCommand extends AbstractDatabaseServerCommand */ public const NAME = 'database:server:tunnel'; + /** + * The SSH executable. + * + * @var SshExecutable + */ + protected $sshExecutable; + + /** + * Constructor. + */ + public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, ProjectConfiguration $projectConfiguration, SshExecutable $sshExecutable) + { + parent::__construct($apiClient, $cliConfiguration, $projectConfiguration); + + $this->sshExecutable = $sshExecutable; + } + /** * {@inheritdoc} */ @@ -68,7 +88,7 @@ protected function perform() $this->output->info(sprintf('Opening SSH tunnel to the "%s" database server. You can connect using: localhost:%s', $databaseServer['name'], $localPort)); - $tunnel = Ssh::tunnelBastionHost($network->get('bastion_host'), $localPort, $databaseServer['endpoint'], 3306); + $tunnel = $this->sshExecutable->openTunnelToBastionHost($network->get('bastion_host'), $localPort, $databaseServer['endpoint'], 3306); $this->output->info('Once finished, press "Ctrl+C" to close the tunnel'); diff --git a/src/Command/Database/ExportDatabaseCommand.php b/src/Command/Database/ExportDatabaseCommand.php index 71386ff..780b5be 100644 --- a/src/Command/Database/ExportDatabaseCommand.php +++ b/src/Command/Database/ExportDatabaseCommand.php @@ -23,6 +23,7 @@ use Ymir\Cli\Database\Connection; use Ymir\Cli\Database\Mysqldump; use Ymir\Cli\Exception\InvalidInputException; +use Ymir\Cli\Executable\SshExecutable; use Ymir\Cli\Process\Process; use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; @@ -45,9 +46,9 @@ class ExportDatabaseCommand extends AbstractDatabaseCommand /** * Constructor. */ - public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, Filesystem $filesystem, ProjectConfiguration $projectConfiguration) + public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, Filesystem $filesystem, ProjectConfiguration $projectConfiguration, SshExecutable $sshExecutable) { - parent::__construct($apiClient, $cliConfiguration, $projectConfiguration); + parent::__construct($apiClient, $cliConfiguration, $projectConfiguration, $sshExecutable); $this->filesystem = $filesystem; } @@ -88,7 +89,7 @@ protected function perform() $tunnel = null; if (!$connection->needsSshTunnel()) { - $tunnel = $this->startSshTunnel($connection->getDatabaseServer()); + $tunnel = $this->openSshTunnel($connection->getDatabaseServer()); } $this->output->infoWithDelayWarning(sprintf('Exporting "%s" database', $connection->getDatabase())); diff --git a/src/Command/Database/ImportDatabaseCommand.php b/src/Command/Database/ImportDatabaseCommand.php index f896851..5840416 100644 --- a/src/Command/Database/ImportDatabaseCommand.php +++ b/src/Command/Database/ImportDatabaseCommand.php @@ -23,6 +23,7 @@ use Ymir\Cli\Database\Connection; use Ymir\Cli\Database\PDO; use Ymir\Cli\Exception\InvalidInputException; +use Ymir\Cli\Executable\SshExecutable; use Ymir\Cli\Process\Process; use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; @@ -45,9 +46,9 @@ class ImportDatabaseCommand extends AbstractDatabaseCommand /** * Constructor. */ - public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, Filesystem $filesystem, ProjectConfiguration $projectConfiguration) + public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, Filesystem $filesystem, ProjectConfiguration $projectConfiguration, SshExecutable $sshExecutable) { - parent::__construct($apiClient, $cliConfiguration, $projectConfiguration); + parent::__construct($apiClient, $cliConfiguration, $projectConfiguration, $sshExecutable); $this->filesystem = $filesystem; } @@ -77,7 +78,7 @@ protected function perform() $tunnel = null; if ($connection->needsSshTunnel()) { - $tunnel = $this->startSshTunnel($connection->getDatabaseServer()); + $tunnel = $this->openSshTunnel($connection->getDatabaseServer()); } $this->output->infoWithDelayWarning(sprintf('Importing "%s" to the "%s" database', $filename, $connection->getDatabase())); diff --git a/src/Command/Docker/DeleteDockerImagesCommand.php b/src/Command/Docker/DeleteDockerImagesCommand.php index cb2cc50..e8ac1f4 100644 --- a/src/Command/Docker/DeleteDockerImagesCommand.php +++ b/src/Command/Docker/DeleteDockerImagesCommand.php @@ -15,8 +15,11 @@ use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputOption; +use Ymir\Cli\ApiClient; +use Ymir\Cli\CliConfiguration; use Ymir\Cli\Command\AbstractCommand; -use Ymir\Cli\Tool\Docker; +use Ymir\Cli\Executable\DockerExecutable; +use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; class DeleteDockerImagesCommand extends AbstractCommand { @@ -34,6 +37,23 @@ class DeleteDockerImagesCommand extends AbstractCommand */ private const ALL_PATTERN = 'dkr.ecr'; + /** + * The Docker executable. + * + * @var DockerExecutable + */ + private $dockerExecutable; + + /** + * Constructor. + */ + public function __construct(ApiClient $apiClient, DockerExecutable $dockerExecutable, CliConfiguration $cliConfiguration, ProjectConfiguration $projectConfiguration) + { + parent::__construct($apiClient, $cliConfiguration, $projectConfiguration); + + $this->dockerExecutable = $dockerExecutable; + } + /** * {@inheritdoc} */ @@ -60,7 +80,7 @@ protected function perform() return; } - Docker::rmigrep($pattern); + $this->dockerExecutable->removeImagesMatchingPattern($pattern); $this->output->info('Deployment docker images deleted successfully'); } diff --git a/src/Command/Project/ConfigureProjectCommand.php b/src/Command/Project/ConfigureProjectCommand.php index 54b2d80..b1c192a 100644 --- a/src/Command/Project/ConfigureProjectCommand.php +++ b/src/Command/Project/ConfigureProjectCommand.php @@ -20,10 +20,10 @@ use Ymir\Cli\CliConfiguration; use Ymir\Cli\Command\AbstractProjectCommand; use Ymir\Cli\Exception\InvalidInputException; +use Ymir\Cli\Executable\WpCliExecutable; use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; use Ymir\Cli\ProjectConfiguration\WordPress\WordPressConfigurationChangeInterface; use Ymir\Cli\Support\Arr; -use Ymir\Cli\Tool\WpCli; class ConfigureProjectCommand extends AbstractProjectCommand { @@ -48,14 +48,22 @@ class ConfigureProjectCommand extends AbstractProjectCommand */ private $configurationChanges; + /** + * The WP-CLI executable. + * + * @var WpCliExecutable + */ + private $wpCliExecutable; + /** * Constructor. */ - public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, ProjectConfiguration $projectConfiguration, iterable $configurationChanges = []) + public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, ProjectConfiguration $projectConfiguration, WpCliExecutable $wpCliExecutable, iterable $configurationChanges = []) { parent::__construct($apiClient, $cliConfiguration, $projectConfiguration); $this->configurationChanges = new Collection(); + $this->wpCliExecutable = $wpCliExecutable; foreach ($configurationChanges as $configurationChange) { $this->addConfigurationChange($configurationChange); @@ -79,9 +87,9 @@ protected function configure() */ protected function perform() { - if (!WpCli::isInstalledGlobally()) { + if ($this->wpCliExecutable->isInstalled()) { throw new RuntimeException('WP-CLI needs to be available globally to scan your project'); - } elseif (!WpCli::isWordPressInstalled()) { + } elseif (!$this->wpCliExecutable->isWordPressInstalled()) { throw new RuntimeException('WordPress needs to be installed and connected to a database to scan your project'); } @@ -93,7 +101,7 @@ protected function perform() $this->output->info('Scanning your project'); - $plugins = WpCli::listPlugins()->groupBy('status'); + $plugins = $this->wpCliExecutable->listPlugins()->groupBy('status'); $activePlugins = $plugins->only(['active', 'must-use'])->flatten(1); $inactivePlugins = $plugins->only(['inactive'])->flatten(1); diff --git a/src/Command/Project/InitializeProjectCommand.php b/src/Command/Project/InitializeProjectCommand.php index df078a9..312313e 100644 --- a/src/Command/Project/InitializeProjectCommand.php +++ b/src/Command/Project/InitializeProjectCommand.php @@ -24,11 +24,11 @@ use Ymir\Cli\Command\Docker\CreateDockerfileCommand; use Ymir\Cli\Command\InstallPluginCommand; use Ymir\Cli\Command\Provider\ConnectProviderCommand; +use Ymir\Cli\Executable\DockerExecutable; +use Ymir\Cli\Executable\WpCliExecutable; use Ymir\Cli\Process\Process; use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; use Ymir\Cli\Support\Arr; -use Ymir\Cli\Tool\Docker; -use Ymir\Cli\Tool\WpCli; class InitializeProjectCommand extends AbstractCommand { @@ -46,6 +46,13 @@ class InitializeProjectCommand extends AbstractCommand */ public const NAME = 'project:init'; + /** + * Docker executable. + * + * @var DockerExecutable + */ + private $dockerExecutable; + /** * The file system. * @@ -60,15 +67,24 @@ class InitializeProjectCommand extends AbstractCommand */ private $projectDirectory; + /** + * The WP-CLI executable. + * + * @var WpCliExecutable + */ + private $wpCliExecutable; + /** * Constructor. */ - public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, Filesystem $filesystem, ProjectConfiguration $projectConfiguration, string $projectDirectory) + public function __construct(ApiClient $apiClient, CliConfiguration $cliConfiguration, DockerExecutable $dockerExecutable, Filesystem $filesystem, ProjectConfiguration $projectConfiguration, string $projectDirectory, WpCliExecutable $wpCliExecutable) { parent::__construct($apiClient, $cliConfiguration, $projectConfiguration); + $this->dockerExecutable = $dockerExecutable; $this->filesystem = $filesystem; $this->projectDirectory = rtrim($projectDirectory, '/'); + $this->wpCliExecutable = $wpCliExecutable; } /** @@ -142,11 +158,11 @@ protected function perform() $this->invoke(InstallPluginCommand::NAME); } - if ($this->output->confirm('Do you want to deploy this project using a container image?', Docker::isInstalledGlobally())) { + if ($this->output->confirm('Do you want to deploy this project using a container image?', $this->dockerExecutable->isInstalled())) { $this->invoke(CreateDockerfileCommand::NAME, ['--configure-project' => null]); } - if (WpCli::isInstalledGlobally() && WpCli::isWordPressInstalled() && $this->output->confirm('Do you want to have Ymir scan your plugins and themes and configure your project?')) { + if ($this->wpCliExecutable->isInstalled() && $this->wpCliExecutable->isWordPressInstalled() && $this->output->confirm('Do you want to have Ymir scan your plugins and themes and configure your project?')) { $this->invoke(ConfigureProjectCommand::NAME); } }, 'Do you want to try creating a project again?'); @@ -194,7 +210,7 @@ private function checkForWordPress(string $projectType) Process::runShellCommandline('composer create-project roots/bedrock .'); } elseif ('wordpress' === $projectType) { $this->output->info('Downloading WordPress using WP-CLI'); - WpCli::downloadWordPress(); + $this->wpCliExecutable->downloadWordPress(); } $this->output->info('WordPress downloaded successfully'); @@ -276,7 +292,7 @@ private function getBaseEnvironmentsConfiguration(string $projectType): Collecti */ private function isPluginInstalled(string $projectType): bool { - return ('wordpress' === $projectType && WpCli::isYmirPluginInstalled()) + return ('wordpress' === $projectType && $this->wpCliExecutable->isYmirPluginInstalled()) || ('bedrock' === $projectType && str_contains((string) file_get_contents('./composer.json'), 'ymirapp/wordpress-plugin')); } @@ -287,7 +303,7 @@ private function isWordPressDownloadable(string $projectType): bool { try { return in_array($projectType, ['bedrock', 'wordpress']) - && !WpCli::isWordPressInstalled() + && !$this->wpCliExecutable->isWordPressInstalled() && ('bedrock' !== $projectType || !(new \FilesystemIterator($this->projectDirectory))->valid()); } catch (\Throwable $exception) { return false; diff --git a/src/Deployment/UploadFunctionCodeStep.php b/src/Deployment/UploadFunctionCodeStep.php index 3929105..d946401 100644 --- a/src/Deployment/UploadFunctionCodeStep.php +++ b/src/Deployment/UploadFunctionCodeStep.php @@ -18,10 +18,10 @@ use Ymir\Cli\ApiClient; use Ymir\Cli\Console\Input; use Ymir\Cli\Console\Output; +use Ymir\Cli\Executable\DockerExecutable; use Ymir\Cli\FileUploader; use Ymir\Cli\ProjectConfiguration\ProjectConfiguration; use Ymir\Cli\Support\Arr; -use Ymir\Cli\Tool\Docker; class UploadFunctionCodeStep implements DeploymentStepInterface { @@ -46,6 +46,13 @@ class UploadFunctionCodeStep implements DeploymentStepInterface */ private $buildDirectory; + /** + * The Docker executable. + * + * @var DockerExecutable + */ + private $dockerExecutable; + /** * The Ymir project configuration. * @@ -63,11 +70,12 @@ class UploadFunctionCodeStep implements DeploymentStepInterface /** * Constructor. */ - public function __construct(ApiClient $apiClient, string $buildArtifactPath, string $buildDirectory, ProjectConfiguration $projectConfiguration, FileUploader $uploader) + public function __construct(ApiClient $apiClient, string $buildArtifactPath, string $buildDirectory, DockerExecutable $dockerExecutable, ProjectConfiguration $projectConfiguration, FileUploader $uploader) { $this->apiClient = $apiClient; $this->buildArtifactPath = $buildArtifactPath; $this->buildDirectory = rtrim($buildDirectory, '/'); + $this->dockerExecutable = $dockerExecutable; $this->projectConfiguration = $projectConfiguration; $this->uploader = $uploader; } @@ -99,9 +107,9 @@ private function pushImage(Collection $deployment, string $environment, Output $ $output->infoWithDelayWarning('Pushing container image'); - Docker::login($user, $password, Arr::get((array) explode('/', $imageUri), 0), $this->buildDirectory); - Docker::tag(sprintf('%s:%s', $this->projectConfiguration->getProjectName(), $environment), $imageUri, $this->buildDirectory); - Docker::push($imageUri, $this->buildDirectory); + $this->dockerExecutable->login($user, $password, Arr::get((array) explode('/', $imageUri), 0), $this->buildDirectory); + $this->dockerExecutable->tag(sprintf('%s:%s', $this->projectConfiguration->getProjectName(), $environment), $imageUri, $this->buildDirectory); + $this->dockerExecutable->push($imageUri, $this->buildDirectory); } /** diff --git a/src/Exception/CommandLineToolNotDetectedException.php b/src/Exception/Executable/ExecutableNotDetectedException.php similarity index 66% rename from src/Exception/CommandLineToolNotDetectedException.php rename to src/Exception/Executable/ExecutableNotDetectedException.php index eb7bfa4..31f0b68 100644 --- a/src/Exception/CommandLineToolNotDetectedException.php +++ b/src/Exception/Executable/ExecutableNotDetectedException.php @@ -11,17 +11,18 @@ * file that was distributed with this source code. */ -namespace Ymir\Cli\Exception; +namespace Ymir\Cli\Exception\Executable; use Symfony\Component\Console\Exception\RuntimeException; +use Ymir\Cli\Executable\ExecutableInterface; -class CommandLineToolNotDetectedException extends RuntimeException +class ExecutableNotDetectedException extends RuntimeException { /** * Constructor. */ - public function __construct($name) + public function __construct(ExecutableInterface $executable) { - parent::__construct(sprintf('Cannot detect %1$s on this computer. Please ensure %1$s is installed and properly configured.', $name)); + parent::__construct(sprintf('Cannot detect %1$s on this computer. Please ensure %1$s is installed and properly configured.', $executable->getDisplayName())); } } diff --git a/src/Exception/Executable/SshPortInUseException.php b/src/Exception/Executable/SshPortInUseException.php new file mode 100644 index 0000000..6f9e80f --- /dev/null +++ b/src/Exception/Executable/SshPortInUseException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Cli\Exception\Executable; + +use Symfony\Component\Console\Exception\RuntimeException; + +class SshPortInUseException extends RuntimeException +{ + /** + * Constructor. + */ + public function __construct(int $port) + { + parent::__construct(sprintf('Unable to open SSH tunnel. Local port "%s" is already in use.', $port)); + } +} diff --git a/src/Exception/WpCliException.php b/src/Exception/Executable/WpCliException.php similarity index 90% rename from src/Exception/WpCliException.php rename to src/Exception/Executable/WpCliException.php index 0ecb29b..84a5b4e 100644 --- a/src/Exception/WpCliException.php +++ b/src/Exception/Executable/WpCliException.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace Ymir\Cli\Exception; +namespace Ymir\Cli\Exception\Executable; use Symfony\Component\Console\Exception\RuntimeException; diff --git a/src/Executable/AbstractExecutable.php b/src/Executable/AbstractExecutable.php new file mode 100644 index 0000000..5b6bcf3 --- /dev/null +++ b/src/Executable/AbstractExecutable.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Cli\Executable; + +use Ymir\Cli\Exception\Executable\ExecutableNotDetectedException; +use Ymir\Cli\Process\Process; + +abstract class AbstractExecutable implements ExecutableInterface +{ + /** + * {@inheritdoc} + */ + public function isInstalled(): bool + { + return $this->isExecutableInstalled($this->getExecutable()); + } + + /** + * Get an unstarted Process object to run the executable with the given command. + */ + protected function getProcess(string $command, ?string $cwd = null, ?float $timeout = 60): Process + { + if (!$this->isInstalled()) { + throw new ExecutableNotDetectedException($this); + } + + return Process::fromShellCommandline(sprintf('%s %s', $this->getExecutable(), $command), $cwd, null, null, $timeout); + } + + /** + * Check if the given executable is installed. + */ + protected function isExecutableInstalled(string $executable): bool + { + return 0 === Process::fromShellCommandline(sprintf('which %s', $executable))->run(); + } + + /** + * Run the executable with the given command and return the Process object used to run it. + */ + protected function run(string $command, ?string $cwd = null, ?float $timeout = 60): Process + { + if (!$this->isInstalled()) { + throw new ExecutableNotDetectedException($this); + } + + return Process::runShellCommandline(sprintf('%s %s', $this->getExecutable(), $command), $cwd, null, null, $timeout); + } +} diff --git a/src/Tool/Docker.php b/src/Executable/DockerExecutable.php similarity index 55% rename from src/Tool/Docker.php rename to src/Executable/DockerExecutable.php index d915d20..959d230 100644 --- a/src/Tool/Docker.php +++ b/src/Executable/DockerExecutable.php @@ -11,43 +11,59 @@ * file that was distributed with this source code. */ -namespace Ymir\Cli\Tool; +namespace Ymir\Cli\Executable; use Symfony\Component\Console\Exception\RuntimeException; -class Docker extends CommandLineTool +class DockerExecutable extends AbstractExecutable { /** * Build a docker image. */ - public static function build(string $file, string $tag, ?string $cwd = null) + public function build(string $file, string $tag, ?string $cwd = null) { - self::runCommand(sprintf('build --pull --file=%s --tag=%s .', $file, $tag), $cwd); + $this->run(sprintf('build --pull --file=%s --tag=%s .', $file, $tag), $cwd); + } + + /** + * {@inheritdoc} + */ + public function getDisplayName(): string + { + return 'Docker'; + } + + /** + * {@inheritdoc} + */ + public function getExecutable(): string + { + return 'docker'; } /** * Login to a Docker registry. */ - public static function login(string $username, string $password, string $server, ?string $cwd = null) + public function login(string $username, string $password, string $server, ?string $cwd = null) { - self::runCommand(sprintf('login --username %s --password %s %s', $username, $password, $server), $cwd); + $this->run(sprintf('login --username %s --password %s %s', $username, $password, $server), $cwd); } /** * Push a docker image. */ - public static function push(string $image, ?string $cwd = null) + public function push(string $image, ?string $cwd = null) { - self::runCommand(sprintf('push %s', $image), $cwd); + $this->run(sprintf('push %s', $image), $cwd); } /** * Remove all images matching grep pattern. */ - public static function rmigrep(string $pattern, ?string $cwd = null) + public function removeImagesMatchingPattern(string $pattern, ?string $cwd = null) { try { - self::runCommand(sprintf('rmi -f $(docker images | grep \'%s\')', $pattern), $cwd); + $this->run(sprintf('rmi -f $(docker images | grep \'%s\')', $pattern), $cwd); } catch (RuntimeException $exception) { $throwException = collect([ '"docker rmi" requires at least 1 argument', @@ -65,24 +81,8 @@ public static function rmigrep(string $pattern, ?string $cwd = null) /** * Create a docker image tag. */ - public static function tag(string $sourceImage, string $targetImage, ?string $cwd = null) + public function tag(string $sourceImage, string $targetImage, ?string $cwd = null) { - self::runCommand(sprintf('tag %s %s', $sourceImage, $targetImage), $cwd); - } - - /** - * {@inheritdoc} - */ - protected static function getCommand(): string - { - return 'docker'; - } - - /** - * {@inheritdoc} - */ - protected static function getName(): string - { - return 'Docker'; + $this->run(sprintf('tag %s %s', $sourceImage, $targetImage), $cwd); } } diff --git a/src/Executable/ExecutableInterface.php b/src/Executable/ExecutableInterface.php new file mode 100644 index 0000000..a3c7982 --- /dev/null +++ b/src/Executable/ExecutableInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Cli\Executable; + +interface ExecutableInterface +{ + /** + * Get the human-readable name for this executable. + */ + public function getDisplayName(): string; + + /** + * Get the actual binary name or command string used to invoke this executable. + */ + public function getExecutable(): string; + + /** + * Determines if this command-line executable is installed and accessible globally. + */ + public function isInstalled(): bool; +} diff --git a/src/Executable/SshExecutable.php b/src/Executable/SshExecutable.php new file mode 100644 index 0000000..a3597a9 --- /dev/null +++ b/src/Executable/SshExecutable.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Cli\Executable; + +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Filesystem\Filesystem; +use Ymir\Cli\Exception\Executable\SshPortInUseException; +use Ymir\Cli\Process\Process; + +class SshExecutable extends AbstractExecutable +{ + /** + * The file system. + * + * @var Filesystem + */ + private $filesystem; + + /** + * The SSH directory. + * + * @var string + */ + private $sshDirectory; + + /** + * Constructor. + */ + public function __construct(Filesystem $filesystem, ?string $sshDirectory = null) + { + $this->filesystem = $filesystem; + $this->sshDirectory = $sshDirectory ?? rtrim((string) getenv('HOME'), '/').'/.ssh'; + } + + public function getDisplayName(): string + { + return 'SSH'; + } + + /** + * {@inheritdoc} + */ + public function getExecutable(): string + { + return 'ssh'; + } + + /** + * Opens an SSH tunnel to a bastion host and returns the running tunnel process. + */ + public function openTunnelToBastionHost(array $bastionHost, int $localPort, string $remoteHost, int $remotePort, ?string $cwd = null): Process + { + if (!isset($bastionHost['endpoint'], $bastionHost['private_key'])) { + throw new InvalidArgumentException('Bastion host configuration must contain an "endpoint" and a "private_key"'); + } + + if (!is_dir($this->sshDirectory)) { + $this->filesystem->mkdir($this->sshDirectory, 0700); + } + + $identityFilePath = $this->sshDirectory.'/ymir-tunnel'; + + $this->filesystem->dumpFile($identityFilePath, $bastionHost['private_key']); + $this->filesystem->chmod($identityFilePath, 0600); + + $process = $this->getProcess(sprintf('ec2-user@%s -i %s -o LogLevel=debug -L %s:%s:%s -N', $bastionHost['endpoint'], $identityFilePath, $localPort, $remoteHost, $remotePort), $cwd, null); + $process->start(function ($type, $buffer) use ($localPort) { + if (Process::ERR === $type && false !== stripos($buffer, sprintf('%s: address already in use', $localPort))) { + throw new SshPortInUseException($localPort); + } + }); + + return $process; + } +} diff --git a/src/Tool/WpCli.php b/src/Executable/WpCliExecutable.php similarity index 57% rename from src/Tool/WpCli.php rename to src/Executable/WpCliExecutable.php index e9f842a..04eac0d 100644 --- a/src/Tool/WpCli.php +++ b/src/Executable/WpCliExecutable.php @@ -11,30 +11,46 @@ * file that was distributed with this source code. */ -namespace Ymir\Cli\Tool; +namespace Ymir\Cli\Executable; use Illuminate\Support\Collection; use Symfony\Component\Console\Exception\RuntimeException; -use Ymir\Cli\Exception\WpCliException; +use Ymir\Cli\Exception\Executable\WpCliException; use Ymir\Cli\Process\Process; -class WpCli extends CommandLineTool +class WpCliExecutable extends AbstractExecutable { /** * Download WordPress. */ - public static function downloadWordPress(?string $cwd = null) + public function downloadWordPress(?string $cwd = null) { - self::runCommand('core download', $cwd); + $this->run('core download', $cwd); + } + + /** + * {@inheritdoc} + */ + public function getDisplayName(): string + { + return 'WP-CLI'; + } + + /** + * {@inheritdoc} + */ + public function getExecutable(): string + { + return 'wp'; } /** * Checks if WordPress is installed. */ - public static function isWordPressInstalled(?string $cwd = null): bool + public function isWordPressInstalled(?string $cwd = null): bool { try { - self::runCommand('core is-installed', $cwd); + $this->run('core is-installed', $cwd); return true; } catch (WpCliException $exception) { @@ -45,10 +61,10 @@ public static function isWordPressInstalled(?string $cwd = null): bool /** * Checks if the Ymir plugin is installed. */ - public static function isYmirPluginInstalled(?string $cwd = null): bool + public function isYmirPluginInstalled(?string $cwd = null): bool { try { - return self::listPlugins($cwd)->contains(function (array $plugin) { + return $this->listPlugins($cwd)->contains(function (array $plugin) { return !empty($plugin['file']) && 1 === preg_match('/\/ymir\.php$/', $plugin['file']); }); } catch (\Throwable $exception) { @@ -59,46 +75,30 @@ public static function isYmirPluginInstalled(?string $cwd = null): bool /** * List all the installed plugins. */ - public static function listPlugins(?string $cwd = null): Collection + public function listPlugins(?string $cwd = null): Collection { - $process = self::runCommand('plugin list --fields=file,name,status,title,version --format=json', $cwd); + $process = $this->run('plugin list --fields=file,name,status,title,version --format=json', $cwd); - $plugins = collect(json_decode($process->getOutput(), true)); + $plugins = json_decode($process->getOutput(), true); - if ($plugins->isEmpty()) { + if (JSON_ERROR_NONE !== json_last_error()) { throw new RuntimeException('Unable to get the list of installed plugins'); } - return $plugins; - } - - /** - * {@inheritdoc} - */ - protected static function getCommand(): string - { - return 'wp'; - } - - /** - * {@inheritdoc} - */ - protected static function getName(): string - { - return 'WP-CLI'; + return collect($plugins); } /** * {@inheritdoc} */ - protected static function runCommand(string $command, ?string $cwd = null): Process + protected function run(string $command, ?string $cwd = null, ?float $timeout = 60): Process { if (function_exists('posix_geteuid') && 0 === posix_geteuid()) { - throw new RuntimeException('WP-CLI commands can only be run as a non-root user'); + throw new WpCliException('WP-CLI commands can only be run as a non-root user'); } try { - return parent::runCommand($command, $cwd); + return parent::run($command, $cwd, $timeout); } catch (RuntimeException $exception) { throw new WpCliException($exception->getMessage(), $exception->getCode(), $exception); } diff --git a/src/Tool/CommandLineTool.php b/src/Tool/CommandLineTool.php deleted file mode 100644 index cb23102..0000000 --- a/src/Tool/CommandLineTool.php +++ /dev/null @@ -1,69 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Ymir\Cli\Tool; - -use Symfony\Component\Console\Exception\RuntimeException; -use Ymir\Cli\Exception\CommandLineToolNotDetectedException; -use Ymir\Cli\Process\Process; - -class CommandLineTool -{ - /** - * Checks if command-line tool is installed globally. - */ - public static function isInstalledGlobally(): bool - { - return 0 === Process::fromShellCommandline(sprintf('which %s', static::getCommand()))->run(); - } - - /** - * Get the command to interact with the command-line tool. - */ - protected static function getCommand(): string - { - throw new RuntimeException('Must override "getCommand" method'); - } - - /** - * Get the name of the command-line tool. - */ - protected static function getName(): string - { - throw new RuntimeException('Must override "getName" method'); - } - - /** - * Get a Process object for the given command. - */ - protected static function getProcess(string $command, ?string $cwd = null, ?float $timeout = 60): Process - { - if (!static::isInstalledGlobally()) { - throw new CommandLineToolNotDetectedException(static::getName()); - } - - return Process::fromShellCommandline(sprintf('%s %s', static::getCommand(), $command), $cwd, null, null, $timeout); - } - - /** - * Run command. - */ - protected static function runCommand(string $command, ?string $cwd = null): Process - { - if (!static::isInstalledGlobally()) { - throw new CommandLineToolNotDetectedException(static::getName()); - } - - return Process::runShellCommandline(sprintf('%s %s', static::getCommand(), $command), $cwd, null, null, null); - } -} diff --git a/src/Tool/Ssh.php b/src/Tool/Ssh.php deleted file mode 100644 index 6c90930..0000000 --- a/src/Tool/Ssh.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Ymir\Cli\Tool; - -use Symfony\Component\Console\Exception\InvalidArgumentException; -use Symfony\Component\Console\Exception\RuntimeException; -use Symfony\Component\Filesystem\Filesystem; -use Ymir\Cli\Process\Process; - -class Ssh extends CommandLineTool -{ - /** - * Creates an SSH tunnel to a bastion host and returns the running process. - */ - public static function tunnelBastionHost(array $bastionHost, int $localPort, string $remoteHost, int $remotePort, ?string $cwd = null): Process - { - if (!isset($bastionHost['endpoint'], $bastionHost['private_key'])) { - throw new InvalidArgumentException('Invalid bastion host given'); - } - - $filesystem = new Filesystem(); - $sshDirectory = rtrim((string) getenv('HOME'), '/').'/.ssh'; - - if (!is_dir($sshDirectory)) { - $filesystem->mkdir($sshDirectory, 0700); - } - - $identityFilePath = $sshDirectory.'/ymir-tunnel'; - - $filesystem->dumpFile($identityFilePath, $bastionHost['private_key']); - $filesystem->chmod($identityFilePath, 0600); - - $command = sprintf('ec2-user@%s -i %s -o LogLevel=debug -L %s:%s:%s -N', $bastionHost['endpoint'], $identityFilePath, $localPort, $remoteHost, $remotePort); - - $process = self::getProcess($command, $cwd, null); - $process->start(function ($type, $buffer) use ($localPort) { - if (Process::ERR !== $type) { - return; - } - - if (false !== stripos($buffer, sprintf('%s: address already in use', $localPort))) { - throw new RuntimeException(sprintf('Unable to create SSH tunnel. Local port "%s" is already in use.', $localPort)); - } - }); - - return $process; - } - - /** - * {@inheritdoc} - */ - protected static function getCommand(): string - { - return 'ssh'; - } - - /** - * {@inheritdoc} - */ - protected static function getName(): string - { - return 'SSH'; - } -}