diff --git a/automatic_updates.module b/automatic_updates.module index 2b020f3af540af196ac0dbdc2002faa0648f3ac3..fd71e968d7fb265487612715d3ba5a5c86defabe 100644 --- a/automatic_updates.module +++ b/automatic_updates.module @@ -6,8 +6,10 @@ */ use Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface; -use Drupal\update\UpdateManagerInterface; use Drupal\Core\Url; +use Drupal\update\UpdateManagerInterface; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; /** * Implements hook_page_top(). @@ -91,20 +93,35 @@ function automatic_updates_cron() { $not_recommended_version = $projects['drupal']['status'] !== UpdateManagerInterface::CURRENT; $security_update = in_array($projects['drupal']['status'], [UpdateManagerInterface::NOT_SECURE, UpdateManagerInterface::REVOKED], TRUE); $recommended_release = isset($projects['drupal']['releases'][$projects['drupal']['recommended']]) ? $projects['drupal']['releases'][$projects['drupal']['recommended']] : NULL; - $major_upgrade = isset($recommended_release['version_major']) ? $recommended_release['version_major'] !== $projects['drupal']['existing_major'] : TRUE; - // Don't automatically update major version bumps or a dev version of core. - if (!$major_upgrade && $config->get('enable_cron_security_updates')) { - if ($not_recommended_version && $security_update) { + $existing_minor_version = explode('.', \Drupal::VERSION, -1); + $recommended_minor_version = explode('.', $recommended_release['version'], -1); + $major_upgrade = $existing_minor_version !== $recommended_minor_version; + if ($major_upgrade) { + foreach (range(1, 30) as $point_version) { + $potential_version = implode('.', array_merge($existing_minor_version, (array) $point_version)); + if (isset($available['drupal']['releases'][$potential_version])) { + $recommended_release = $available['drupal']['releases'][$potential_version]; + } + else { + break; + } + } + } + // Don't automatically update major version bumps or from/to same version. + if ($not_recommended_version && $projects['drupal']['existing_version'] !== $recommended_release['version']) { + if ($config->get('enable_cron_security_updates')) { + if ($security_update) { + /** @var \Drupal\automatic_updates\Services\UpdateInterface $updater */ + $updater = \Drupal::service('automatic_updates.update'); + $updater->update('drupal', 'core', \Drupal::VERSION, $recommended_release['version']); + } + } + else { /** @var \Drupal\automatic_updates\Services\UpdateInterface $updater */ $updater = \Drupal::service('automatic_updates.update'); $updater->update('drupal', 'core', \Drupal::VERSION, $recommended_release['version']); } } - elseif (!$major_upgrade && $not_recommended_version) { - /** @var \Drupal\automatic_updates\Services\UpdateInterface $updater */ - $updater = \Drupal::service('automatic_updates.update'); - $updater->update('drupal', 'core', \Drupal::VERSION, $recommended_release['version']); - } } /** @var \Drupal\automatic_updates\Services\NotifyInterface $notify */ @@ -141,3 +158,30 @@ function automatic_updates_mail($key, &$message, $params) { $message['subject'] = $params['subject']; $message['body'][] = $renderer->render($params['body']); } + +/** + * Helper method to execute console command. + * + * @param string $command_argument + * The command argument. + * + * @return \Symfony\Component\Process\Process + * The console command process. + */ +function automatic_updates_console_command($command_argument) { + $module_path = drupal_get_path('module', 'automatic_updates'); + $command = [ + (new PhpExecutableFinder())->find(), + $module_path . '/scripts/automatic_update_tools', + $command_argument, + '--script-filename', + \Drupal::request()->server->get('SCRIPT_FILENAME'), + '--base-url', + \Drupal::request()->getBaseUrl(), + '--base-path', + \Drupal::request()->getBasePath(), + ]; + $process = new Process($command, (string) \Drupal::root(), NULL, NULL, NULL); + $process->run(); + return $process; +} diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index 35402f431bf4a7e29a636b1309f48dd92090a386..53dab8ea0ae4a0b7ccceb0a49342661c7ba68e1c 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -43,6 +43,9 @@ services: - '@file_system' - '@http_client' - '@app.root' + plugin.manager.database_update_handler: + class: Drupal\automatic_updates\DatabaseUpdateHandlerPluginManager + parent: default_plugin_manager automatic_updates.readiness_checker: class: Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManager @@ -92,6 +95,8 @@ services: - { name: readiness_checker, category: warning} automatic_updates.pending_db_updates: class: Drupal\automatic_updates\ReadinessChecker\PendingDbUpdates + arguments: + - '@update.post_update_registry' tags: - { name: readiness_checker, category: error} automatic_updates.missing_project_info: diff --git a/config/install/automatic_updates.settings.yml b/config/install/automatic_updates.settings.yml index d3cc904507bb1658be5e59f96cd44d88000c4faf..a6d4891a6f8a70d2139004ed9c7bee9525b97e40 100644 --- a/config/install/automatic_updates.settings.yml +++ b/config/install/automatic_updates.settings.yml @@ -8,3 +8,7 @@ ignored_paths: "modules/*\nthemes/*\nprofiles/*" download_uri: 'https://www.drupal.org/in-place-updates' enable_cron_updates: false enable_cron_security_updates: false +database_update_handling: + - maintenance_mode_activate + - execute_updates + - maintenance_mode_disactivate diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml index d3197e60380b5bb7b522c992a1ed6146f15293ab..77515d7334deba2675c680ab1b7d08136a8c93a5 100644 --- a/config/schema/automatic_updates.schema.yml +++ b/config/schema/automatic_updates.schema.yml @@ -32,3 +32,9 @@ automatic_updates.settings: enable_cron_security_updates: type: boolean label: 'Enable automatic updates for security releases via cron' + database_update_handling: + type: sequence + label: 'Database update handling' + sequence: + type: string + label: 'Tagged service to handle database updates' diff --git a/scripts/automatic_update_tools b/scripts/automatic_update_tools new file mode 100644 index 0000000000000000000000000000000000000000..78da7dbeb9ef7d650d5573f3279647a6ef7e6204 --- /dev/null +++ b/scripts/automatic_update_tools @@ -0,0 +1,41 @@ +#!/usr/bin/env php +<?php + +/** + * @file + * Provides helper commands for automatic updates. + * + * This must be a separate application so class caches aren't an issue during + * in place updates. + */ + +use Drupal\automatic_updates\Command\CacheRebuild; +use Drupal\automatic_updates\Command\DatabaseUpdate; +use Drupal\automatic_updates\Command\DatabaseUpdateStatus; +use Symfony\Component\Console\Application; + +if (PHP_SAPI !== 'cli') { + return; +} + +// Set up autoloader +$loader = false; +if (file_exists($autoloader = __DIR__ . '/../../../../autoload.php') + || file_exists($autoloader = __DIR__ . '/../../../../../autoload.php') + || file_exists($autoloader = __DIR__ . '/../../../autoload.php') +) { + /** @var \Composer\Autoload\ClassLoader $loader */ + $loader = require_once $autoloader; + // Drupal's autoloader doesn't bootstrap this module's classes yet. Do so + // manually. + $loader->addPsr4('Drupal\\automatic_updates\\', __DIR__ . '/../src'); +} +else { + throw new \RuntimeException('Could not locate autoload.php; __DIR__ is ' . __DIR__); +} + +$application = new Application('automatic_update_tools', 'stable'); +$application->add(new CacheRebuild($loader)); +$application->add(new DatabaseUpdate($loader)); +$application->add(new DatabaseUpdateStatus($loader)); +$application->run(); diff --git a/src/Annotation/DatabaseUpdateHandler.php b/src/Annotation/DatabaseUpdateHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..8e881e8c2ef49a6a0c6c6a3532f5ab3574870740 --- /dev/null +++ b/src/Annotation/DatabaseUpdateHandler.php @@ -0,0 +1,39 @@ +<?php + +namespace Drupal\automatic_updates\Annotation; + +use Drupal\Component\Annotation\Plugin; + +/** + * Defines a DatabaseUpdateHandler annotation object. + * + * Plugin Namespace: Plugin\DatabaseUpdateHandler. + * + * For a working example, see + * \Drupal\automatic_updates\Plugin\DatabaseUpdateHandler\MaintenanceMode. + * + * @see \Drupal\automatic_updates\DatabaseUpdateHandlerInterface + * @see \Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase + * @see \Drupal\automatic_updates\DatabaseUpdateHandlerPluginManager + * @see hook_database_update_handler_plugin_info_alter() + * @see plugin_api + * + * @Annotation + */ +final class DatabaseUpdateHandler extends Plugin { + + /** + * The ID of the handler, should match the service name. + * + * @var string + */ + public $id; + + /** + * The name of the handler. + * + * @var string + */ + public $label; + +} diff --git a/src/Command/BaseCommand.php b/src/Command/BaseCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..268d029fc5acbbe58338da5d120e256bdbda1ac8 --- /dev/null +++ b/src/Command/BaseCommand.php @@ -0,0 +1,71 @@ +<?php + +namespace Drupal\automatic_updates\Command; + +use Drupal\Core\DrupalKernel; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Base command class. + */ +class BaseCommand extends Command { + + /** + * The class loader. + * + * @var object + */ + protected $classLoader; + + /** + * Constructs a new InstallCommand command. + * + * @param object $class_loader + * The class loader. + */ + public function __construct($class_loader) { + $this->classLoader = $class_loader; + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() { + parent::configure(); + $this->addOption('script-filename', NULL, InputOption::VALUE_REQUIRED, 'The script filename') + ->addOption('base-url', NULL, InputOption::VALUE_REQUIRED, 'The base URL, i.e. http://example.com/index.php') + ->addOption('base-path', NULL, InputOption::VALUE_REQUIRED, 'The base path, i.e. http://example.com'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) { + $this->bootstrapDrupal($input); + } + + /** + * Bootstrap Drupal. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * The input. + */ + protected function bootstrapDrupal(InputInterface $input) { + $kernel = new DrupalKernel('prod', $this->classLoader); + $script_filename = $input->getOption('script-filename'); + $base_url = $input->getOption('base-url'); + $base_path = $input->getOption('base-path'); + $server = [ + 'SCRIPT_FILENAME' => $script_filename, + 'SCRIPT_NAME' => $base_url, + ]; + $request = Request::create($base_path, 'GET', [], [], [], $server); + $kernel->handle($request); + } + +} diff --git a/src/Command/CacheRebuild.php b/src/Command/CacheRebuild.php new file mode 100644 index 0000000000000000000000000000000000000000..5bc2c594fb9c73656c191ce1f1392a4011e6b343 --- /dev/null +++ b/src/Command/CacheRebuild.php @@ -0,0 +1,32 @@ +<?php + +namespace Drupal\automatic_updates\Command; + +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Cache rebuild command. + */ +class CacheRebuild extends BaseCommand { + + /** + * {@inheritdoc} + */ + protected function configure() { + parent::configure(); + $this->setName('cache:rebuild') + ->setAliases(['cr, rebuild']) + ->setDescription('Rebuild a Drupal site and clear all its caches.'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) { + parent::execute($input, $output); + drupal_flush_all_caches(); + $output->writeln('Cache rebuild complete.'); + } + +} diff --git a/src/Command/DatabaseUpdate.php b/src/Command/DatabaseUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..c88cc62fbe7ef81a1e1bff5f1e3b24cfd62c476a --- /dev/null +++ b/src/Command/DatabaseUpdate.php @@ -0,0 +1,102 @@ +<?php + +namespace Drupal\automatic_updates\Command; + +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Database update command. + */ +class DatabaseUpdate extends BaseCommand { + + /** + * {@inheritdoc} + */ + protected function configure() { + parent::configure(); + $this->setName('updatedb') + ->setDescription('Apply any database updates required (as with running update.php).') + ->setAliases(['updb']); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) { + parent::execute($input, $output); + $pending_updates = \Drupal::service('automatic_updates.pending_db_updates') + ->run(); + if ($pending_updates) { + $output->writeln('Started database updates.'); + $this->executeDatabaseUpdates(); + $output->writeln('Finished database updates.'); + } + else { + $output->writeln('No database updates required.'); + } + } + + /** + * Execute all outstanding database updates. + */ + protected function executeDatabaseUpdates() { + require_once DRUPAL_ROOT . '/core/includes/install.inc'; + require_once DRUPAL_ROOT . '/core/includes/update.inc'; + $logger = \Drupal::logger('automatic_updates'); + drupal_load_updates(); + $start = $dependency_map = $operations = []; + foreach (update_get_update_list() as $module => $update) { + $start[$module] = $update['start']; + } + $updates = update_resolve_dependencies($start); + foreach ($updates as $function => $update) { + $dependency_map[$function] = !empty($update['reverse_paths']) ? array_keys($update['reverse_paths']) : []; + } + foreach ($updates as $function => $update) { + if ($update['allowed']) { + // Set the installed version of each module so updates will start at the + // correct place. (The updates are already sorted, so we can simply base + // this on the first one we come across in the above foreach loop.) + if (isset($start[$update['module']])) { + drupal_set_installed_schema_version($update['module'], $update['number'] - 1); + unset($start[$update['module']]); + } + $this->executeDatabaseUpdate('update_do_one', [ + $update['module'], + $update['number'], + $dependency_map[$function], + ]); + } + } + + $post_updates = \Drupal::service('update.post_update_registry')->getPendingUpdateFunctions(); + if ($post_updates) { + // Now we rebuild all caches and after that execute hook_post_update(). + $logger->info('Starting cache clear pre-step of database update.'); + automatic_updates_console_command('cache:rebuild'); + $logger->info('Finished cache clear pre-step of database update.'); + foreach ($post_updates as $function) { + $this->executeDatabaseUpdate('update_invoke_post_update', [$function]); + } + } + } + + /** + * Execute a single database update. + * + * @param callable $invoker + * Callable update invoker. + * @param array $args + * The arguments to pass to the invoker. + */ + protected function executeDatabaseUpdate(callable $invoker, array $args) { + \Drupal::logger('automatic_updates')->notice('Database update running with arguments "@arguments"', ['@arguments' => print_r($args, TRUE)]); + $context = [ + 'sandbox' => [], + ]; + call_user_func_array($invoker, array_merge($args, [&$context])); + \Drupal::logger('automatic_updates')->notice('Database update finished with arguments "@arguments"', ['@arguments' => print_r($args, TRUE)]); + } + +} diff --git a/src/Command/DatabaseUpdateStatus.php b/src/Command/DatabaseUpdateStatus.php new file mode 100644 index 0000000000000000000000000000000000000000..b8e1085a82eb0e447741adf30e16af341dd1ec0a --- /dev/null +++ b/src/Command/DatabaseUpdateStatus.php @@ -0,0 +1,33 @@ +<?php + +namespace Drupal\automatic_updates\Command; + +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Database update status command. + */ +class DatabaseUpdateStatus extends BaseCommand { + + /** + * {@inheritdoc} + */ + protected function configure() { + parent::configure(); + $this->setName('updatedb-status') + ->setDescription('Apply any database updates required (as with running update.php).') + ->setAliases(['updbst', 'updatedb:status']); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) { + parent::execute($input, $output); + $pending_updates = \Drupal::service('automatic_updates.pending_db_updates') + ->run(); + $output->writeln($pending_updates); + } + +} diff --git a/src/DatabaseUpdateHandlerInterface.php b/src/DatabaseUpdateHandlerInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..0d14e3374c48d3dc5fed81459ee50b393eb68bc4 --- /dev/null +++ b/src/DatabaseUpdateHandlerInterface.php @@ -0,0 +1,26 @@ +<?php + +namespace Drupal\automatic_updates; + +/** + * Interface for database_update_handler plugins. + */ +interface DatabaseUpdateHandlerInterface { + + /** + * Returns the translated plugin label. + * + * @return string + * The translated title. + */ + public function label(); + + /** + * Handle database updates. + * + * @return bool + * TRUE if database update was handled successfully, FALSE otherwise. + */ + public function execute(); + +} diff --git a/src/DatabaseUpdateHandlerPluginBase.php b/src/DatabaseUpdateHandlerPluginBase.php new file mode 100644 index 0000000000000000000000000000000000000000..0aef167b5c20dc8b0198ddb781a82ea4163f39a7 --- /dev/null +++ b/src/DatabaseUpdateHandlerPluginBase.php @@ -0,0 +1,19 @@ +<?php + +namespace Drupal\automatic_updates; + +use Drupal\Component\Plugin\PluginBase; + +/** + * Base class for database_update_handler plugins. + */ +abstract class DatabaseUpdateHandlerPluginBase extends PluginBase implements DatabaseUpdateHandlerInterface { + + /** + * {@inheritdoc} + */ + public function label() { + return $this->pluginDefinition['label']; + } + +} diff --git a/src/DatabaseUpdateHandlerPluginManager.php b/src/DatabaseUpdateHandlerPluginManager.php new file mode 100644 index 0000000000000000000000000000000000000000..5fc1da1facda7dc9d6397009965e28929d5d5b88 --- /dev/null +++ b/src/DatabaseUpdateHandlerPluginManager.php @@ -0,0 +1,37 @@ +<?php + +namespace Drupal\automatic_updates; + +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Plugin\DefaultPluginManager; + +/** + * DatabaseUpdateHandler plugin manager. + */ +class DatabaseUpdateHandlerPluginManager extends DefaultPluginManager { + + /** + * Constructs DatabaseUpdateHandlerPluginManager object. + * + * @param \Traversable $namespaces + * An object that implements \Traversable which contains the root paths + * keyed by the corresponding namespace to look for plugin implementations. + * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend + * Cache backend instance to use. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler to invoke the alter hook with. + */ + public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) { + parent::__construct( + 'Plugin/DatabaseUpdateHandler', + $namespaces, + $module_handler, + 'Drupal\automatic_updates\DatabaseUpdateHandlerInterface', + 'Drupal\automatic_updates\Annotation\DatabaseUpdateHandler' + ); + $this->alterInfo('database_update_handler_info'); + $this->setCacheBackend($cache_backend, 'database_update_handler_plugins'); + } + +} diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php index b18a9d404200c471afd79474005ad1a907b6d111..025d52c5e1a3cb7a7205cb5e74d75461303c0e27 100644 --- a/src/Form/SettingsForm.php +++ b/src/Form/SettingsForm.php @@ -40,6 +40,13 @@ class SettingsForm extends ConfigFormBase { */ protected $updateManager; + /** + * The update processor. + * + * @var \Drupal\update\UpdateProcessorInterface + */ + protected $updateProcessor; + /** * {@inheritdoc} */ @@ -49,6 +56,7 @@ class SettingsForm extends ConfigFormBase { $instance->dateFormatter = $container->get('date.formatter'); $instance->drupalRoot = (string) $container->get('app.root'); $instance->updateManager = $container->get('update.manager'); + $instance->updateProcessor = $container->get('update.processor'); return $instance; } @@ -126,6 +134,7 @@ class SettingsForm extends ConfigFormBase { ]; $this->updateManager->refreshUpdateData(); + $this->updateProcessor->fetchData(); $available = update_get_available(TRUE); $projects = update_calculate_project_data($available); $not_recommended_version = $projects['drupal']['status'] !== UpdateManagerInterface::CURRENT; diff --git a/src/Plugin/DatabaseUpdateHandler/ExecuteUpdates.php b/src/Plugin/DatabaseUpdateHandler/ExecuteUpdates.php new file mode 100644 index 0000000000000000000000000000000000000000..8ce8de571f67517ec0884beb6ccd098cba8ce755 --- /dev/null +++ b/src/Plugin/DatabaseUpdateHandler/ExecuteUpdates.php @@ -0,0 +1,68 @@ +<?php + +namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler; + +use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Execute database updates. + * + * @DatabaseUpdateHandler( + * id = "execute_updates", + * label = "Execute database updates", + * ) + */ +class ExecuteUpdates extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface { + + /** + * The logger. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * Constructs a new maintenance mode service. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Psr\Log\LoggerInterface $logger + * The logger. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('logger.channel.automatic_updates') + ); + } + + /** + * {@inheritdoc} + */ + public function execute() { + $process = automatic_updates_console_command('updatedb'); + if ($errors = $process->getErrorOutput()) { + $this->logger->error($errors); + return FALSE; + } + return TRUE; + } + +} diff --git a/src/Plugin/DatabaseUpdateHandler/IgnoreUpdates.php b/src/Plugin/DatabaseUpdateHandler/IgnoreUpdates.php new file mode 100644 index 0000000000000000000000000000000000000000..3a8325b678113e42d015c72384c6d7f3638fab59 --- /dev/null +++ b/src/Plugin/DatabaseUpdateHandler/IgnoreUpdates.php @@ -0,0 +1,65 @@ +<?php + +namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler; + +use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Ignore database updates. + * + * @DatabaseUpdateHandler( + * id = "ignore_updates", + * label = "Ignore database updates", + * ) + */ +class IgnoreUpdates extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface { + + /** + * The logger. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * Constructs a new maintenance mode service. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Psr\Log\LoggerInterface $logger + * The logger. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('logger.channel.automatic_updates') + ); + } + + /** + * {@inheritdoc} + */ + public function execute() { + $this->logger->notice('Database updates ignored.'); + // Ignore the updates and hope for the best. + return TRUE; + } + +} diff --git a/src/Plugin/DatabaseUpdateHandler/MaintenanceModeActivate.php b/src/Plugin/DatabaseUpdateHandler/MaintenanceModeActivate.php new file mode 100644 index 0000000000000000000000000000000000000000..663aafc8a3decea0faf6df40e1ebac94b58b636d --- /dev/null +++ b/src/Plugin/DatabaseUpdateHandler/MaintenanceModeActivate.php @@ -0,0 +1,77 @@ +<?php + +namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler; + +use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\State\StateInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Put site into maintenance mode if there are database updates. + * + * @DatabaseUpdateHandler( + * id = "maintenance_mode_activate", + * label = "Put site into maintenance mode", + * ) + */ +class MaintenanceModeActivate extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface { + + /** + * The state. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * The logger. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * Constructs a new maintenance mode service. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\State\StateInterface $state + * The state. + * @param \Psr\Log\LoggerInterface $logger + * The logger. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, StateInterface $state, LoggerInterface $logger) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->state = $state; + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('state'), + $container->get('logger.channel.automatic_updates') + ); + } + + /** + * {@inheritdoc} + */ + public function execute() { + $this->logger->notice('Maintenance mode activated.'); + $this->state->set('system.maintenance_mode', TRUE); + return TRUE; + } + +} diff --git a/src/Plugin/DatabaseUpdateHandler/MaintenanceModeDisactivate.php b/src/Plugin/DatabaseUpdateHandler/MaintenanceModeDisactivate.php new file mode 100644 index 0000000000000000000000000000000000000000..212f71b03412d345a4c4ca10399fe3e253b6567d --- /dev/null +++ b/src/Plugin/DatabaseUpdateHandler/MaintenanceModeDisactivate.php @@ -0,0 +1,77 @@ +<?php + +namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler; + +use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\State\StateInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Remove site from maintenance mode. + * + * @DatabaseUpdateHandler( + * id = "maintenance_mode_disactivate", + * label = "Remove site from maintenance mode", + * ) + */ +class MaintenanceModeDisactivate extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface { + + /** + * The state. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * The logger. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * Constructs a new maintenance mode service. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\State\StateInterface $state + * The state. + * @param \Psr\Log\LoggerInterface $logger + * The logger. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, StateInterface $state, LoggerInterface $logger) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->state = $state; + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('state'), + $container->get('logger.channel.automatic_updates') + ); + } + + /** + * {@inheritdoc} + */ + public function execute() { + $this->logger->notice('Maintenance mode dis-activated.'); + $this->state->set('system.maintenance_mode', FALSE); + return TRUE; + } + +} diff --git a/src/Plugin/DatabaseUpdateHandler/RollbackUpdate.php b/src/Plugin/DatabaseUpdateHandler/RollbackUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..b20ec368218fcec31d88ef6ff841941b16eb1177 --- /dev/null +++ b/src/Plugin/DatabaseUpdateHandler/RollbackUpdate.php @@ -0,0 +1,65 @@ +<?php + +namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler; + +use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Rollback database updates. + * + * @DatabaseUpdateHandler( + * id = "rollback", + * label = "Rollback database updates", + * ) + */ +class RollbackUpdate extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface { + + /** + * The logger. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * Constructs a new maintenance mode service. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Psr\Log\LoggerInterface $logger + * The logger. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('logger.channel.automatic_updates') + ); + } + + /** + * {@inheritdoc} + */ + public function execute() { + $this->logger->notice('Rollback initiated due to database updates.'); + // Simply rollback the update by returning FALSE. + return FALSE; + } + +} diff --git a/src/ReadinessChecker/MinimumPhpVersion.php b/src/ReadinessChecker/MinimumPhpVersion.php index e9edc6fdf8ac3f32ce00d849e21f37e2d2d5903f..29f42c1e236ff45b55ccf0df8727674926208124 100644 --- a/src/ReadinessChecker/MinimumPhpVersion.php +++ b/src/ReadinessChecker/MinimumPhpVersion.php @@ -16,6 +16,8 @@ class MinimumPhpVersion extends SupportedPhpVersion { */ protected function getUnsupportedVersionConstraint() { $parser = new VersionParser(); + // Constant was introduced in 8.7.0-beta1, back fill for full 8.7 support. + defined('DRUPAL_MINIMUM_SUPPORTED_PHP') or define('DRUPAL_MINIMUM_SUPPORTED_PHP', '7.0.8'); return $parser->parseConstraints('<' . DRUPAL_MINIMUM_SUPPORTED_PHP); } diff --git a/src/ReadinessChecker/PendingDbUpdates.php b/src/ReadinessChecker/PendingDbUpdates.php index 4cd4c6676f051ececc23bf53e1e0285eb360157e..e7a311b8c09f79b6a077bd405b414af3c71145f0 100644 --- a/src/ReadinessChecker/PendingDbUpdates.php +++ b/src/ReadinessChecker/PendingDbUpdates.php @@ -3,6 +3,7 @@ namespace Drupal\automatic_updates\ReadinessChecker; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Update\UpdateRegistry; /** * Pending database updates checker. @@ -10,6 +11,23 @@ use Drupal\Core\StringTranslation\StringTranslationTrait; class PendingDbUpdates implements ReadinessCheckerInterface { use StringTranslationTrait; + /** + * The update registry. + * + * @var \Drupal\Core\Update\UpdateRegistry + */ + protected $updateRegistry; + + /** + * PendingDbUpdates constructor. + * + * @param \Drupal\Core\Update\UpdateRegistry $update_registry + * The update registry. + */ + public function __construct(UpdateRegistry $update_registry) { + $this->updateRegistry = $update_registry; + } + /** * {@inheritdoc} */ @@ -17,7 +35,7 @@ class PendingDbUpdates implements ReadinessCheckerInterface { $messages = []; if ($this->areDbUpdatesPending()) { - $messages[] = $this->t('There are pending database updates, therefore updates cannot be applied. Please run update.php.'); + $messages[] = $this->t('There are pending database updates. Please run update.php.'); } return $messages; } @@ -32,7 +50,9 @@ class PendingDbUpdates implements ReadinessCheckerInterface { require_once DRUPAL_ROOT . '/core/includes/install.inc'; require_once DRUPAL_ROOT . '/core/includes/update.inc'; drupal_load_updates(); - return (bool) update_get_update_list(); + $hook_updates = update_get_update_list(); + $post_updates = $this->updateRegistry->getPendingUpdateFunctions(); + return (bool) array_merge($hook_updates, $post_updates); } } diff --git a/src/ReadinessChecker/ReadinessCheckerManager.php b/src/ReadinessChecker/ReadinessCheckerManager.php index 0ad6dd6baf0489300ebc7239bb23151fb826f6be..eb1071f9cdb0b6cb1eafc090ca15d338b17f858f 100644 --- a/src/ReadinessChecker/ReadinessCheckerManager.php +++ b/src/ReadinessChecker/ReadinessCheckerManager.php @@ -25,12 +25,12 @@ class ReadinessCheckerManager implements ReadinessCheckerManagerInterface { protected $configFactory; /** - * An unsorted array of arrays of active checkers. + * An unsorted array of active checkers. * - * An associative array. The keys are integers that indicate priority. Values - * are arrays of ReadinessCheckerInterface objects. + * The keys are category, next level is integers that indicate priority. + * Values are arrays of ReadinessCheckerInterface objects. * - * @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerInterface[][] + * @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerInterface[][][] */ protected $checkers = []; diff --git a/src/Services/InPlaceUpdate.php b/src/Services/InPlaceUpdate.php index fdd6f6fbb922d5507d5d90e4fb63078f30c92ef7..3bb44a114a6dbce3962c6e822cb821a1303604cd 100644 --- a/src/Services/InPlaceUpdate.php +++ b/src/Services/InPlaceUpdate.php @@ -145,12 +145,26 @@ class InPlaceUpdate implements UpdateInterface { if ($archive = $this->getArchive($project_name, $from_version, $to_version)) { $modified = $this->checkModifiedFiles($project_name, $project_type, $archive); if (!$modified && $this->backup($archive, $project_root)) { + $this->logger->info('In place update has started.'); $success = $this->processUpdate($archive, $project_root); + $this->logger->info('In place update has finished.'); + if ($success) { + $process = automatic_updates_console_command('updatedb:status'); + if ($success && $process->getOutput()) { + $this->logger->info('Database update handling has started.'); + $success = $this->handleDatabaseUpdates(); + $this->logger->info('Database update handling has finished.'); + } + } if (!$success) { + $this->logger->info('Rollback has started.'); $this->rollback($project_root); + $this->logger->info('Rollback has finished.'); } - else { - $this->clearOpcodeCache(); + if ($success) { + $this->logger->info('Cache clear has started.'); + $this->cacheRebuild(); + $this->logger->info('Cache clear has finished.'); } } } @@ -319,6 +333,8 @@ class InPlaceUpdate implements UpdateInterface { * The location of the downloaded archive. * @param string $csig * The CSIG contents. + * + * @throws \SodiumException */ protected function validateArchive($directory, $csig) { $module_path = drupal_get_path('module', 'automatic_updates'); @@ -345,7 +361,7 @@ class InPlaceUpdate implements UpdateInterface { */ protected function backup(ArchiverInterface $archive, $project_root) { $backup = $this->fileSystem->createFilename('automatic_updates-backup', 'temporary://'); - $this->fileSystem->prepareDirectory($backup); + $this->fileSystem->prepareDirectory($backup, FileSystemInterface::CREATE_DIRECTORY); $this->backup = $this->fileSystem->realpath($backup) . DIRECTORY_SEPARATOR; if (!$this->backup) { return FALSE; @@ -426,17 +442,41 @@ class InPlaceUpdate implements UpdateInterface { if (!$this->backup) { return; } - foreach ($this->getFilesList($this->backup) as $file) { + foreach ($this->getFilesList($this->getTempDirectory()) as $file) { $file_real_path = $this->getFileRealPath($file); - $file_path = substr($file_real_path, strlen($this->backup)); + $file_path = substr($file_real_path, strlen($this->getTempDirectory() . self::ARCHIVE_DIRECTORY)); + $project_real_path = $this->getProjectRealPath($file_path, $project_root); try { - $this->fileSystem->copy($file_real_path, $this->getProjectRealPath($file_path, $project_root), FileSystemInterface::EXISTS_REPLACE); - $this->logger->info('"@file" was restored due to failure(s) in applying update.', ['@file' => $file_path]); + $this->fileSystem->delete($project_real_path); + $this->logger->info('"@file" was successfully removed during rollback.', ['@file' => $project_real_path]); } catch (FileException $exception) { - $this->logger->error('@file was not rolled back successfully.', ['@file' => $file_real_path]); + $this->logger->error('"@file" failed removal on rollback.', ['@file' => $project_real_path]); } } + foreach ($this->getFilesList($this->backup) as $file) { + $this->doRestore($file, $project_root); + } + } + + /** + * Do restore. + * + * @param \SplFileInfo $file + * File to restore. + * @param string $project_root + * The project root directory. + */ + protected function doRestore(\SplFileInfo $file, $project_root) { + $file_real_path = $this->getFileRealPath($file); + $file_path = substr($file_real_path, strlen($this->backup)); + try { + $this->fileSystem->copy($file_real_path, $this->getProjectRealPath($file_path, $project_root), FileSystemInterface::EXISTS_REPLACE); + $this->logger->info('"@file" was successfully restored.', ['@file' => $file_path]); + } + catch (FileException $exception) { + $this->logger->error('"@file" failed restoration during rollback.', ['@file' => $file_real_path]); + } } /** @@ -573,12 +613,31 @@ class InPlaceUpdate implements UpdateInterface { } /** - * Clear Opcode cache on successful update. + * Clear cache on successful update. */ - protected function clearOpcodeCache() { + protected function cacheRebuild() { if (function_exists('opcache_reset')) { opcache_reset(); } + automatic_updates_console_command('cache:rebuild'); + } + + /** + * Handle database updates. + * + * @return bool + * TRUE if database updates were handled successfully. FALSE otherwise. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + protected function handleDatabaseUpdates() { + $result = TRUE; + /** @var \Drupal\Component\Plugin\PluginManagerInterface $database_update_handler */ + $database_update_handler = \Drupal::service('plugin.manager.database_update_handler'); + foreach ($this->configFactory->get('automatic_updates.settings')->get('database_update_handling') as $plugin_id) { + $result = $result && $database_update_handler->createInstance($plugin_id)->execute(); + } + return $result; } } diff --git a/tests/src/Build/InPlaceUpdateTest.php b/tests/src/Build/InPlaceUpdateTest.php index 59d0b2f9638d3d5b4ab6dfed327dbea1a15dc678..c50c019f054bd137448baee3d463aae6be19ae5c 100644 --- a/tests/src/Build/InPlaceUpdateTest.php +++ b/tests/src/Build/InPlaceUpdateTest.php @@ -7,6 +7,7 @@ use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem; use Drupal\Tests\automatic_updates\Build\QuickStart\QuickStartTestBase; use Drupal\Tests\automatic_updates\Traits\InstallTestTrait; use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; use Symfony\Component\Finder\Finder; @@ -49,35 +50,29 @@ class InPlaceUpdateTest extends QuickStartTestBase { /** * @covers ::update - * @dataProvider coreVersionsProvider + * @dataProvider coreVersionsSuccessProvider */ public function testCoreUpdate($from_version, $to_version) { $this->installCore($from_version); + $this->assertCoreUpgradeSuccess($from_version, $to_version); + } - // Assert files slated for deletion still exist. - foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) { - $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); - } - - // Update the site. - $assert = $this->visit("/test_automatic_updates/in-place-update/drupal/core/$from_version/$to_version") - ->assertSession(); - $assert->statusCodeEquals(200); - $this->assertDrupalVisit(); + /** + * @covers ::update + */ + public function testCoreRollbackUpdate() { + $from_version = '8.7.0'; + $to_version = '8.8.0'; + $this->installCore($from_version); - // Assert that the update worked. - $assert->pageTextContains('Update successful'); - $finder = new Finder(); - $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php'); - $finder->contains("/const VERSION = '$to_version'/"); - $this->assertTrue($finder->hasResults()); - $this->visit('/admin/reports/status'); - $assert->pageTextContains("Drupal Version $to_version"); + // Configure module to have db updates cause a rollback. + $settings_php = $this->getWorkspaceDirectory() . '/sites/default/settings.php'; + $fs = new SymfonyFilesystem(); + $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0755); + $fs->chmod($settings_php, 0640); + $fs->appendToFile($settings_php, PHP_EOL . '$config[\'automatic_updates.settings\'][\'database_update_handling\'] = [\'rollback\'];' . PHP_EOL); - // Assert files slated for deletion are now gone. - foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) { - $this->assertFileNotExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); - } + $this->assertCoreUpgradeFailed($from_version, $to_version); } /** @@ -88,7 +83,7 @@ class InPlaceUpdateTest extends QuickStartTestBase { $this->markTestSkipped('Contrib updates are not currently supported'); $this->copyCodebase(); $fs = new SymfonyFilesystem(); - $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000); + $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700); $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction'); $this->assertErrorOutputContains('Generating autoload files'); $this->installQuickStart('standard'); @@ -100,7 +95,7 @@ class InPlaceUpdateTest extends QuickStartTestBase { $finder = new Finder(); $finder->files()->in($this->getWorkspaceDirectory())->path("{$project_type}s/contrib/$project/$project.info.yml"); $finder->contains("/version: '$from_version'/"); - $this->assertTrue($finder->hasResults()); + $this->assertTrue($finder->hasResults(), "Expected version $from_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php"); // Assert files slated for deletion still exist. foreach ($this->getDeletions($project, $from_version, $to_version) as $deletion) { @@ -109,8 +104,10 @@ class InPlaceUpdateTest extends QuickStartTestBase { // Currently, this test has to use extension_discovery_scan_tests so we can // install test modules. - $fs->chmod($this->getWorkspaceDirectory() . '/sites/default/settings.php', 0640, 0000); - file_put_contents($this->getWorkspaceDirectory() . '/sites/default/settings.php', '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL, FILE_APPEND); + $fs = new SymfonyFilesystem(); + $settings_php = $this->getWorkspaceDirectory() . '/sites/default/settings.php'; + $fs->chmod($settings_php, 0640); + $fs->appendToFile($settings_php, '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL); // Log in so that we can install projects. $this->formLogin($this->adminUsername, $this->adminPassword); @@ -134,7 +131,7 @@ class InPlaceUpdateTest extends QuickStartTestBase { $finder = new Finder(); $finder->files()->in($this->getWorkspaceDirectory())->path("{$project_type}s/contrib/$project/$project.info.yml"); $finder->contains("/version: '$to_version'/"); - $this->assertTrue($finder->hasResults()); + $this->assertTrue($finder->hasResults(), "Expected version $to_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php"); $this->assertDrupalVisit(); // Assert files slated for deletion are now gone. @@ -152,7 +149,7 @@ class InPlaceUpdateTest extends QuickStartTestBase { public function testCronCoreUpdate() { $this->installCore('8.7.6'); $filesystem = new SymfonyFilesystem(); - $filesystem->chmod($this->getWorkspaceDirectory() . '/sites/default', 0750, 0000); + $filesystem->chmod($this->getWorkspaceDirectory() . '/sites/default', 0750); $settings_php = $this->getWorkspaceDirectory() . '/sites/default/settings.php'; $filesystem->chmod($settings_php, 0640); $filesystem->appendToFile($settings_php, PHP_EOL . '$config[\'automatic_updates.settings\'][\'enable_cron_updates\'] = TRUE;' . PHP_EOL); @@ -166,7 +163,7 @@ class InPlaceUpdateTest extends QuickStartTestBase { $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php'); $finder->notContains("/const VERSION = '8.7.6'/"); $finder->contains("/const VERSION = '8.7./"); - $this->assertTrue($finder->hasResults()); + $this->assertTrue($finder->hasResults(), "Expected version 8.7.{x} does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php"); } /** @@ -187,15 +184,17 @@ class InPlaceUpdateTest extends QuickStartTestBase { $this->executeCommand('git reset HEAD --hard'); $this->assertCommandSuccessful(); $fs = new SymfonyFilesystem(); - $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000); + $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700); $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction'); $this->assertErrorOutputContains('Generating autoload files'); $this->installQuickStart('minimal'); // Currently, this test has to use extension_discovery_scan_tests so we can // install test modules. - $fs->chmod($this->getWorkspaceDirectory() . '/sites/default/settings.php', 0640, 0000); - file_put_contents($this->getWorkspaceDirectory() . '/sites/default/settings.php', '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL, FILE_APPEND); + $settings_php = $this->getWorkspaceDirectory() . '/sites/default/settings.php'; + $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0755); + $fs->chmod($settings_php, 0640); + $fs->appendToFile($settings_php, '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL); // Log in so that we can install modules. $this->formLogin($this->adminUsername, $this->adminPassword); @@ -207,7 +206,7 @@ class InPlaceUpdateTest extends QuickStartTestBase { $finder = new Finder(); $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php'); $finder->contains("/const VERSION = '$starting_version'/"); - $this->assertTrue($finder->hasResults()); + $this->assertTrue($finder->hasResults(), "Expected version $starting_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php"); // Assert that the site is functional after install. $this->visit(); @@ -215,13 +214,21 @@ class InPlaceUpdateTest extends QuickStartTestBase { } /** - * Core versions data provider. + * Core versions data provider resulting in a successful upgrade. */ - public function coreVersionsProvider() { + public function coreVersionsSuccessProvider() { + $datum[] = [ + 'from' => '8.7.2', + 'to' => '8.7.4', + ]; $datum[] = [ 'from' => '8.7.0', 'to' => '8.7.1', ]; + $datum[] = [ + 'from' => '8.7.2', + 'to' => '8.7.10', + ]; $datum[] = [ 'from' => '8.7.6', 'to' => '8.7.7', @@ -256,13 +263,12 @@ class InPlaceUpdateTest extends QuickStartTestBase { return $this->deletions; } $this->deletions = []; - $http_client = new Client(); $filesystem = new SymfonyFilesystem(); $this->deletionsDestination = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . "$project-" . mt_rand(10000, 99999) . microtime(TRUE); $filesystem->mkdir($this->deletionsDestination); $file_name = "$project-$from_version-to-$to_version.zip"; $zip_file = $this->deletionsDestination . DIRECTORY_SEPARATOR . $file_name; - $http_client->get("https://www.drupal.org/in-place-updates/$project/$file_name", ['sink' => $zip_file]); + $this->doGetArchive($project, $file_name, $zip_file); $zip = new \ZipArchive(); $zip->open($zip_file); $zip->extractTo($this->deletionsDestination, [InPlaceUpdate::DELETION_MANIFEST]); @@ -278,4 +284,111 @@ class InPlaceUpdateTest extends QuickStartTestBase { return $this->deletions; } + /** + * Get the archive with protection against 429s. + * + * @param string $project + * The project. + * @param string $file_name + * The filename. + * @param string $zip_file + * The zip file path. + * @param int $delay + * (optional) The delay. + */ + protected function doGetArchive($project, $file_name, $zip_file, $delay = 0) { + try { + sleep($delay); + $http_client = new Client(); + $http_client->get("https://www.drupal.org/in-place-updates/$project/$file_name", ['sink' => $zip_file]); + } + catch (RequestException $exception) { + $response = $exception->getResponse(); + if ($response && $response->getStatusCode() === 429) { + $this->doGetArchive($project, $file_name, $zip_file, 10); + } + else { + throw $exception; + } + } + } + + /** + * Assert an upgrade succeeded. + * + * @param string $from_version + * The version from which to upgrade. + * @param string $to_version + * The version to which to upgrade. + * + * @throws \Behat\Mink\Exception\ExpectationException + * @throws \Behat\Mink\Exception\ResponseTextException + */ + public function assertCoreUpgradeSuccess($from_version, $to_version) { + // Assert files slated for deletion still exist. + foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) { + $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); + } + + // Update the site. + $assert = $this->visit("/test_automatic_updates/in-place-update/drupal/core/$from_version/$to_version") + ->assertSession(); + $assert->statusCodeEquals(200); + $this->assertDrupalVisit(); + + // Assert that the update worked. + $finder = new Finder(); + $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php'); + $finder->contains("/const VERSION = '$to_version'/"); + $this->assertTrue($finder->hasResults(), "Expected version $to_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php"); + $assert->pageTextContains('Update successful'); + $this->visit('/admin/reports/status'); + $assert->pageTextContains("Drupal Version $to_version"); + + // Assert files slated for deletion are now gone. + foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) { + $this->assertFileNotExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); + } + + // Validate that all DB updates are processed. + $this->visit('/update.php/selection'); + $assert->pageTextContains('No pending updates.'); + } + + /** + * Assert an upgraded failed and was handle appropriately. + * + * @param string $from_version + * The version from which to upgrade. + * @param string $to_version + * The version to which to upgrade. + * + * @throws \Behat\Mink\Exception\ResponseTextException + */ + public function assertCoreUpgradeFailed($from_version, $to_version) { + // Assert files slated for deletion still exist. + foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) { + $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); + } + + // Update the site. + $assert = $this->visit("/test_automatic_updates/in-place-update/drupal/core/$from_version/$to_version") + ->assertSession(); + $assert->statusCodeEquals(200); + + // Assert that the update failed. + $finder = new Finder(); + $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php'); + $finder->contains("/const VERSION = '$from_version'/"); + $this->assertTrue($finder->hasResults(), "Expected version $from_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php"); + $assert->pageTextContains('Update Failed'); + $this->visit('/admin/reports/status'); + $assert->pageTextContains("Drupal Version $from_version"); + + // Assert files slated for deletion are restored. + foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) { + $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); + } + } + } diff --git a/tests/src/Build/ModifiedFilesTest.php b/tests/src/Build/ModifiedFilesTest.php index 57a5f8073c2c0b8d1ed2e1948534c5bc43440a32..76ce2874dbb1aff9dc774bdf2b2de63133cb4130 100644 --- a/tests/src/Build/ModifiedFilesTest.php +++ b/tests/src/Build/ModifiedFilesTest.php @@ -44,7 +44,7 @@ class ModifiedFilesTest extends QuickStartTestBase { // We have to fetch the tags for this shallow repo. It might not be a // shallow clone, therefore we use executeCommand instead of assertCommand. $this->executeCommand('git fetch --unshallow --tags'); - $this->symfonyFileSystem->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000); + $this->symfonyFileSystem->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700); $this->executeCommand('git reset HEAD --hard'); $this->assertCommandSuccessful(); $this->executeCommand("git checkout $version -f"); @@ -118,14 +118,14 @@ class ModifiedFilesTest extends QuickStartTestBase { * The modified files to assert. */ protected function assertModifications($project_type, $project, array $modifications) { - $this->symfonyFileSystem->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000); + $this->symfonyFileSystem->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700); $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction'); $this->assertErrorOutputContains('Generating autoload files'); $this->installQuickStart('minimal'); // Currently, this test has to use extension_discovery_scan_tests so we can // install test modules. - $this->symfonyFileSystem->chmod($this->getWorkspaceDirectory() . '/sites/default', 0750, 0000); + $this->symfonyFileSystem->chmod($this->getWorkspaceDirectory() . '/sites/default', 0750); $settings_php = $this->getWorkspaceDirectory() . '/sites/default/settings.php'; $this->symfonyFileSystem->chmod($settings_php, 0640); $this->symfonyFileSystem->appendToFile($settings_php, PHP_EOL . '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL); diff --git a/tests/src/Kernel/ReadinessChecker/PendingDbUpdatesTest.php b/tests/src/Kernel/ReadinessChecker/PendingDbUpdatesTest.php index 2a93b5e6415ccf20fb782e757722b146ec6470ec..6a693790a6aadda762ad16d8de4516b25a86856e 100644 --- a/tests/src/Kernel/ReadinessChecker/PendingDbUpdatesTest.php +++ b/tests/src/Kernel/ReadinessChecker/PendingDbUpdatesTest.php @@ -27,7 +27,7 @@ class PendingDbUpdatesTest extends KernelTestBase { $this->assertEmpty($messages); $messages = (new TestPendingDbUpdates())->run(); - self::assertEquals('There are pending database updates, therefore updates cannot be applied. Please run update.php.', $messages[0]); + self::assertEquals('There are pending database updates. Please run update.php.', $messages[0]); } } @@ -37,6 +37,11 @@ class PendingDbUpdatesTest extends KernelTestBase { */ class TestPendingDbUpdates extends PendingDbUpdates { + /** + * {@inheritdoc} + */ + public function __construct() {} + /** * {@inheritdoc} */