diff --git a/composer.json b/composer.json index 839851b8933417930a16c95f68bbb8cf3b66a4de..c3f078dfb123b45896ce8e8302dc7862cf3400ba 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "type": "library", "license": "GPL-2.0+", "require": { + "consolidation/annotated-command": "~2", "drupal/console-core": "1.0.2", "psy/psysh": "^0.8.11", "symfony/console": "^3.3", diff --git a/composer.lock b/composer.lock index e64fd735fc5f977b49230f0f377018b2c0b50e04..02c47ed0e2979ff6715e472212989c0cb93e5959 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,108 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "835ca80eb26b4d7e438015632aaeaaa2", + "content-hash": "c8f06b1cc28f9638bdc92415ed78c328", "packages": [ + { + "name": "consolidation/annotated-command", + "version": "2.8.1", + "source": { + "type": "git", + "url": "https://github.com/consolidation/annotated-command.git", + "reference": "7f94009d732922d61408536f9228aca8f22e9135" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/consolidation/annotated-command/zipball/7f94009d732922d61408536f9228aca8f22e9135", + "reference": "7f94009d732922d61408536f9228aca8f22e9135", + "shasum": "" + }, + "require": { + "consolidation/output-formatters": "^3.1.12", + "php": ">=5.4.0", + "psr/log": "^1", + "symfony/console": "^2.8|~3", + "symfony/event-dispatcher": "^2.5|^3", + "symfony/finder": "^2.5|^3" + }, + "require-dev": { + "phpunit/phpunit": "^4.8", + "satooshi/php-coveralls": "^1.0", + "squizlabs/php_codesniffer": "^2.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Consolidation\\AnnotatedCommand\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Greg Anderson", + "email": "greg.1.anderson@greenknowe.org" + } + ], + "description": "Initialize Symfony Console commands from annotated command class methods.", + "time": "2017-10-17T01:48:51+00:00" + }, + { + "name": "consolidation/output-formatters", + "version": "3.1.12", + "source": { + "type": "git", + "url": "https://github.com/consolidation/output-formatters.git", + "reference": "88ef346a1cefb92aab8b57a3214a6d5fc63f5d2a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/consolidation/output-formatters/zipball/88ef346a1cefb92aab8b57a3214a6d5fc63f5d2a", + "reference": "88ef346a1cefb92aab8b57a3214a6d5fc63f5d2a", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "symfony/console": "^2.8|~3", + "symfony/finder": "~2.5|~3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8", + "satooshi/php-coveralls": "^1.0", + "squizlabs/php_codesniffer": "^2.7", + "victorjonsson/markdowndocs": "^1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Consolidation\\OutputFormatters\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Greg Anderson", + "email": "greg.1.anderson@greenknowe.org" + } + ], + "description": "Format text by applying transformations provided by plug-in formatters.", + "time": "2017-10-12T19:38:03+00:00" + }, { "name": "dflydev/dot-access-configuration", "version": "v1.0.2", diff --git a/src/Command/ServicesCommand.php b/src/Command/ServicesCommand.php index 1629650f7eb4ba99907ad3258ead447c509d92f7..89baac15f2cefdb92f214301ff29e4b5ecd2d5ef 100644 --- a/src/Command/ServicesCommand.php +++ b/src/Command/ServicesCommand.php @@ -7,6 +7,7 @@ use Aegir\Provision\Context; use Aegir\Provision\Context\PlatformContext; use Aegir\Provision\Context\ServerContext; use Aegir\Provision\Context\SiteContext; +use Consolidation\AnnotatedCommand\CommandFileDiscovery; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputDefinition; @@ -109,4 +110,58 @@ class ServicesCommand extends Command $this->io->comment("List Services"); $this->context->showServices($this->io); } + + /** + * Add a new service to a server. + */ + protected function execute_add(InputInterface $input, OutputInterface $output) + { + // Ask which service. + $this->io->comment("Add Services"); + $service = $this->io->choice('Which service?', $this->context->getServiceOptions()); + + // Then ask which service type + $service_type = $this->io->choice('Which service type?', $this->context->getServiceTypeOptions($service)); + + // Then ask for all options. + $properties = $this->askForServiceProperties($service); + + $this->io->info("Adding $service service $service_type..."); + + try { + $this->context->config['services'][$service] = [ + 'type' => $service_type, + 'properties' => $properties, + ]; + $this->context->save(); + $this->io->success('Service saved to Context!'); + } + catch (\Exception $e) { + throw new \Exception("Something went wrong when saving the context: " . $e->getMessage()); + } + } + + /** + * Loop through this context type's option_documentation() method and ask for each property. + * + * @return array + */ + private function askForServiceProperties($service) { + + $class = $this->context->getAvailableServices($service); + + $options = $class::option_documentation(); + $properties = []; + foreach ($options as $name => $description) { + // If option does not exist, ask for it. + if (!empty($this->input->hasOption($name))) { + $properties[$name] = $this->input->getOption($name); + $this->io->comment("Using option {$name}={$properties[$name]}"); + } + else { + $properties[$name] = $this->io->ask("$name ($description)"); + } + } + return $properties; + } } diff --git a/src/Context/ServerContext.php b/src/Context/ServerContext.php index 58cacc1e3875f28b314610bc6a142fea71e19c22..39226d35881c191e9a0f3b33fb3dea5a942bf0a9 100644 --- a/src/Context/ServerContext.php +++ b/src/Context/ServerContext.php @@ -3,6 +3,7 @@ namespace Aegir\Provision\Context; use Aegir\Provision\Context; +use Consolidation\AnnotatedCommand\CommandFileDiscovery; use Drupal\Console\Core\Style\DrupalStyle; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -56,6 +57,86 @@ class ServerContext extends Context implements ConfigurationInterface } /** + * Loads all available \Aegir\Provision\Service classes + * + * @return array + */ + public function getAvailableServices($service = NULL) { + + // Load all service classes + $classes = []; + $discovery = new CommandFileDiscovery(); + $discovery->setSearchPattern('*Service.php'); + $servicesFiles = $discovery->discover(__DIR__ .'/../Service', '\Aegir\Provision\Service'); + + foreach ($servicesFiles as $serviceClass) { + $classes[$serviceClass::SERVICE] = $serviceClass; + } + + if ($service && isset($classes[$service])) { + return $classes[$service]; + } + elseif ($service && !isset($classes[$service])) { + throw new \Exception("No service with name $service was found."); + } + else { + return $classes; + } + } + + /** + * Lists all available services as a simple service => name array. + * @return array + */ + public function getServiceOptions() { + $options = []; + $services = $this->getAvailableServices(); + foreach ($services as $service => $class) { + $options[$service] = $class::SERVICE_NAME; + } + return $options; + } + + /** + * @return array + */ + protected function getAvailableServiceTypes($service, $service_type = NULL) { + + // Load all service classes + $classes = []; + $discovery = new CommandFileDiscovery(); + $discovery->setSearchPattern(ucfirst($service) . '*Service.php'); + $serviceTypesFiles = $discovery->discover(__DIR__ .'/../Service/' . ucfirst($service), '\Aegir\Provision\Service\\' . ucfirst($service)); + foreach ($serviceTypesFiles as $serviceTypeClass) { + $classes[$serviceTypeClass::SERVICE_TYPE] = $serviceTypeClass; + } + + if ($service_type && isset($classes[$service_type])) { + return $classes[$service_type]; + } + elseif ($service_type && !isset($classes[$service_type])) { + throw new \Exception("No service type with name $service_type was found."); + } + else { + return $classes; + } + } + + /** + * Lists all available services as a simple service => name array. + * @return array + */ + public function getServiceTypeOptions($service) { + $options = []; + $service_types = $this->getAvailableServiceTypes($service); + foreach ($service_types as $service_type => $class) { + $options[$service_type] = $class::SERVICE_TYPE_NAME; + } + return $options; + } + + + /** * Return all services for this context. * * @return array @@ -65,7 +146,7 @@ class ServerContext extends Context implements ConfigurationInterface } /** - * {@inheritdoc} + * Pass server specific config to Context configTreeBuilder. */ public function configTreeBuilder(&$root_node) { @@ -74,11 +155,38 @@ class ServerContext extends Context implements ConfigurationInterface ->arrayNode('services') ->prototype('array') ->children() - ->scalarNode('name') + ->scalarNode('type') ->isRequired(true) ->end() - ->end() - ->end(); + ->append($this->addServiceProperties()) + ->end() + ->end(); + } + + /** + * Append Service class options_documentation to config tree. + */ + public function addServiceProperties() + { + $builder = new TreeBuilder(); + $node = $builder->root('properties'); + + // Load config tree from Service type classes + if (!empty($this->getProperty('services')) && !empty($this->getProperty('services'))) { + foreach ($this->getProperty('services') as $service => $info) { + $service = ucfirst($service); + $service_type = ucfirst($info['type']); + $class = "\Aegir\Provision\Service\\{$service}\\{$service}{$service_type}Service"; + foreach ($class::option_documentation() as $name => $description) { + $node + ->children() + ->scalarNode($name)->end() + ->end() + ->end(); + } + } + } + return $node; } public function verify() { @@ -94,7 +202,14 @@ class ServerContext extends Context implements ConfigurationInterface if (!empty($this->getServices())) { $rows = []; foreach ($this->getServices() as $name => $service) { - $rows[] = [$name, $service['name']]; + $rows[] = [$name, $service['type']]; + + // Show all properties. + if (!empty($service['properties'] )) { + foreach ($service['properties'] as $name => $value) { + $rows[] = [' ' . $name, $value]; + } + } } $io->table(['Services'], $rows); } diff --git a/src/Service.php b/src/Service.php index 1ea8bdd57a69d7937d80008fc17cdb966bb83621..f8fe3852b6387e2ea530f521433d3faf70f04a29 100644 --- a/src/Service.php +++ b/src/Service.php @@ -28,7 +28,19 @@ class Service { */ public $context; - protected $service = NULL; + /** + * @var string + * The machine name of the service. ie. http, db + */ + const SERVICE = 'service'; + + /** + * @var string + * A descriptive name of the service. ie. Web Server + */ + const SERVICE_NAME = 'Service Name'; + + protected $application_name = NULL; protected $has_restart_cmd = FALSE; diff --git a/src/Service/Db/DbMysqlService.php b/src/Service/Db/DbMysqlService.php new file mode 100644 index 0000000000000000000000000000000000000000..f52769721a1547d85ad6bdf7c66728c0bcc78b48 --- /dev/null +++ b/src/Service/Db/DbMysqlService.php @@ -0,0 +1,22 @@ +setProperty('db_server', '@server_master'); + $context->is_oid('db_server'); + $context->service_subscribe('db', $context->db_server->name); + } + + static function option_documentation() { + return array( + 'master_db' => 'server with db: Master database connection info, {type}://{user}:{password}@{host}', + 'db_grant_all_hosts' => 'Grant access to site database users from any web host. If set to TRUE, any host will be allowed to connect to MySQL site databases on this server using the generated username and password. If set to FALSE, web hosts will be granted access by their detected IP address.', + ); + } + + function init_server() { + parent::init_server(); + $this->server->setProperty('master_db'); + $this->server->setProperty('db_grant_all_hosts', FALSE); + $this->server->setProperty('utf8mb4_is_supported', FALSE); + $this->creds = array_map('urldecode', parse_url($this->server->master_db)); + + return TRUE; + } + + function save_server() { + // Check database 4 byte UTF-8 support and save it for later. + $this->server->utf8mb4_is_supported = $this->utf8mb4_is_supported(); + } + + /** + * Verifies database connection and commands + */ + function verify_server_cmd() { + if ($this->connect()) { + if ($this->can_create_database()) { + drush_log(dt('Provision can create new databases.'), 'success'); + } + else { + drush_set_error('PROVISION_CREATE_DB_FAILED'); + } + if ($this->can_grant_privileges()) { + drush_log(dt('Provision can grant privileges on database users.'), 'success');; + } + else { + drush_set_error('PROVISION_GRANT_DB_USER_FAILED'); + } + if ($this->server->utf8mb4_is_supported) { + drush_log(dt('Provision can activate multi-byte UTF-8 support on Drupal 7 sites.'), 'success'); + } + else { + drush_log(dt('Multi-byte UTF-8 for Drupal 7 is not supported on your system. See the documentation on adding 4 byte UTF-8 support for more information.', array('@url' => 'https://www.drupal.org/node/2754539')), 'warning'); + } + } else { + drush_set_error('PROVISION_CONNECT_DB_FAILED'); + } + } + + /** + * Find a viable database name, based on the site's uri. + */ + function suggest_db_name() { + $uri = $this->context->uri; + + $suggest_base = substr(str_replace(array('.', '-'), '' , preg_replace('/^www\./', '', $uri)), 0, 16); + + if (!$this->database_exists($suggest_base)) { + return $suggest_base; + } + + for ($i = 0; $i < 100; $i++) { + $option = sprintf("%s_%d", substr($suggest_base, 0, 15 - strlen( (string) $i) ), $i); + if (!$this->database_exists($option)) { + return $option; + } + } + + drush_set_error('PROVISION_CREATE_DB_FAILED', dt("Could not find a free database names after 100 attempts")); + return false; + } + + /** + * Generate a new mysql database and user account for the specified credentials + */ + function create_site_database($creds = array()) { + if (!sizeof($creds)) { + $creds = $this->generate_site_credentials(); + } + extract($creds); + + if (drush_get_error() || !$this->can_create_database()) { + drush_set_error('PROVISION_CREATE_DB_FAILED'); + drush_log("Database could not be created.", 'error'); + return FALSE; + } + + foreach ($this->grant_host_list() as $db_grant_host) { + drush_log(dt("Granting privileges to %user@%client on %database", array('%user' => $db_user, '%client' => $db_grant_host, '%database' => $db_name))); + if (!$this->grant($db_name, $db_user, $db_passwd, $db_grant_host)) { + drush_set_error('PROVISION_CREATE_DB_FAILED', dt("Could not create database user @user", array('@user' => $db_user))); + } + drush_log(dt("Granted privileges to %user@%client on %database", array('%user' => $db_user, '%client' => $db_grant_host, '%database' => $db_name)), 'success'); + } + + $this->create_database($db_name); + $status = $this->database_exists($db_name); + + if ($status) { + drush_log(dt('Created @name database', array("@name" => $db_name)), 'success'); + } + else { + drush_set_error('PROVISION_CREATE_DB_FAILED', dt("Could not create @name database", array("@name" => $db_name))); + } + return $status; + } + + /** + * Remove the database and user account for the supplied credentials + */ + function destroy_site_database($creds = array()) { + if (!sizeof($creds)) { + $creds = $this->fetch_site_credentials(); + } + extract($creds); + + if ( $this->database_exists($db_name) ) { + drush_log(dt("Dropping database @dbname", array('@dbname' => $db_name))); + if (!$this->drop_database($db_name)) { + drush_log(dt("Failed to drop database @dbname", array('@dbname' => $db_name)), 'warning'); + } + } + + if ( $this->database_exists($db_name) ) { + drush_set_error('PROVISION_DROP_DB_FAILED'); + return FALSE; + } + + foreach ($this->grant_host_list() as $db_grant_host) { + drush_log(dt("Revoking privileges of %user@%client from %database", array('%user' => $db_user, '%client' => $db_grant_host, '%database' => $db_name))); + if (!$this->revoke($db_name, $db_user, $db_grant_host)) { + drush_log(dt("Failed to revoke user privileges"), 'warning'); + } + } + } + + + function import_site_database($dump_file = null, $creds = array()) { + if (is_null($dump_file)) { + $dump_file = d()->site_path . '/database.sql'; + } + + if (!sizeof($creds)) { + $creds = $this->fetch_site_credentials(); + } + + $exists = provision_file()->exists($dump_file) + ->succeed('Found database dump at @path.') + ->fail('No database dump was found at @path.', 'PROVISION_DB_DUMP_NOT_FOUND') + ->status(); + if ($exists) { + $readable = provision_file()->readable($dump_file) + ->succeed('Database dump at @path is readable') + ->fail('The database dump at @path could not be read.', 'PROVISION_DB_DUMP_NOT_READABLE') + ->status(); + if ($readable) { + $this->import_dump($dump_file, $creds); + } + } + } + + function generate_site_credentials() { + $creds = array(); + // replace with service type + $db_type = drush_get_option('db_type', function_exists('mysqli_connect') ? 'mysqli' : 'mysql'); + // As of Drupal 7 there is no more mysqli type + if (drush_drupal_major_version() >= 7) { + $db_type = ($db_type == 'mysqli') ? 'mysql' : $db_type; + } + + //TODO - this should not be here at all + $creds['db_type'] = drush_set_option('db_type', $db_type, 'site'); + $creds['db_host'] = drush_set_option('db_host', $this->server->remote_host, 'site'); + $creds['db_port'] = drush_set_option('db_port', $this->server->db_port, 'site'); + $creds['db_passwd'] = drush_set_option('db_passwd', provision_password(), 'site'); + $creds['db_name'] = drush_set_option('db_name', $this->suggest_db_name(), 'site'); + $creds['db_user'] = drush_set_option('db_user', $creds['db_name'], 'site'); + + return $creds; + } + + function fetch_site_credentials() { + $creds = array(); + + $keys = array('db_type', 'db_port', 'db_user', 'db_name', 'db_host', 'db_passwd'); + foreach ($keys as $key) { + $creds[$key] = drush_get_option($key, '', 'site'); + } + + return $creds; + } + + function database_exists($name) { + return FALSE; + } + + function drop_database($name) { + return FALSE; + } + + function create_database($name) { + return FALSE; + } + + function can_create_database() { + return FALSE; + } + + function can_grant_privileges() { + return FALSE; + } + + function grant($name, $username, $password, $host = '') { + return FALSE; + } + + function revoke($name, $username, $host = '') { + return FALSE; + } + + function import_dump($dump_file, $creds) { + return FALSE; + } + + function generate_dump() { + return FALSE; + } + + /** + * Return a list of hosts, as seen by the db server, which should be granted + * access to the site database. If server property 'db_grant_all_hosts' is + * TRUE, use the MySQL wildcard '%' instead of + */ + function grant_host_list() { + if ($this->server->db_grant_all_hosts) { + return array('%'); + } + else { + return array_unique(array_map(array($this, 'grant_host'), $this->context->service('http')->grant_server_list())); + } + } + + /** + * Return a hostname suitable for database grants from a server object. + */ + function grant_host(Provision_Context_server $server) { + return $server->remote_host; + } + + /** + * Checks whether utf8mb4 support is available on the current database system. + * + * @return bool + */ + function utf8mb4_is_supported() { + // By default we assume that the database backend may not support 4 byte + // UTF-8. + return FALSE; + } +} diff --git a/src/Service/Http/HttpApacheService.php b/src/Service/Http/HttpApacheService.php new file mode 100644 index 0000000000000000000000000000000000000000..7e2812c71aa6311e402049f011e4a43cebb6331b --- /dev/null +++ b/src/Service/Http/HttpApacheService.php @@ -0,0 +1,22 @@ + 'server with http: OS group for permissions; working default will be attempted', + 'web_disable_url' => 'server with http: URL disabled sites are redirected to; default {master_url}/hosting/disabled', + 'web_maintenance_url' => 'server with http: URL maintenance sites are redirected to; default {master_url}/hosting/maintenance', + ); + } + + + /** * Support the ability to cloak the database credentials using environment variables. */ function cloaked_db_creds() {