From 56499f0cc8bee8e84bacc9bb17244f6e0a907335 Mon Sep 17 00:00:00 2001 From: tedbow <tedbow@240860.no-reply.drupal.org> Date: Thu, 14 Oct 2021 18:02:28 +0000 Subject: [PATCH] Issue #3239103 by tedbow, kunal.sachdev, phenaproxima: Add setting to enable/disable updates on cron --- automatic_updates.module | 48 ++++++++ config/install/automatic_updates.settings.yml | 1 + config/schema/automatic_updates.schema.yml | 7 ++ src/CronUpdater.php | 49 +++++++- tests/src/Kernel/CronUpdaterTest.php | 112 ++++++++++++++++++ 5 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 config/install/automatic_updates.settings.yml create mode 100644 config/schema/automatic_updates.schema.yml create mode 100644 tests/src/Kernel/CronUpdaterTest.php diff --git a/automatic_updates.module b/automatic_updates.module index 48b88b1898..f5dbe49308 100644 --- a/automatic_updates.module +++ b/automatic_updates.module @@ -6,9 +6,12 @@ */ use Drupal\automatic_updates\CronUpdater; +use Drupal\automatic_updates\UpdateRecommender; use Drupal\automatic_updates\Validation\AdminReadinessMessages; +use Drupal\Core\Extension\ExtensionVersion; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; +use Drupal\update\ProjectSecurityData; /** * Implements hook_page_top(). @@ -85,6 +88,51 @@ function automatic_updates_form_update_manager_update_form_alter(&$form, FormSta } } +/** + * Implements hook_form_FORM_ID_alter() for 'update_settings' form. + */ +function automatic_updates_form_update_settings_alter(array &$form, FormStateInterface $form_state, string $form_id) { + $recommender = new UpdateRecommender(); + $drupal_project = $recommender->getProjectInfo(); + $version = ExtensionVersion::createFromVersionString($drupal_project['existing_version']); + $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion(); + $supported_until_version = $version->getMajorVersion() . '.' + . ((int) $version->getMinorVersion() + ProjectSecurityData::CORE_MINORS_WITH_SECURITY_COVERAGE) + . '.0'; + + $form['automatic_updates_cron'] = [ + '#type' => 'radios', + '#title' => t('Automatically update Drupal core'), + '#options' => [ + CronUpdater::DISABLED => t('Disabled'), + CronUpdater::ALL => t('All supported updates'), + CronUpdater::SECURITY => t('Security updates only'), + ], + '#default_value' => \Drupal::config('automatic_updates.settings')->get('cron'), + '#description' => t( + 'If enabled, Drupal core will be automatically updated when an update is available. Automatic updates are only supported for @current_minor.x versions of Drupal core. Drupal @current_minor will receive security updates until @supported_until_version is released.', + [ + '@current_minor' => $current_minor, + '@supported_until_version' => $supported_until_version, + ] + ), + ]; + $form += [ + '#submit' => ['::submitForm'], + ]; + $form['#submit'][] = '_automatic_updates_update_settings_form_submit'; +} + +/** + * Submit function for the 'update_settings' form. + */ +function _automatic_updates_update_settings_form_submit(array &$form, FormStateInterface $form_state) { + \Drupal::configFactory() + ->getEditable('automatic_updates.settings') + ->set('cron', $form_state->getValue('automatic_updates_cron')) + ->save(); +} + /** * Implements hook_local_tasks_alter(). */ diff --git a/config/install/automatic_updates.settings.yml b/config/install/automatic_updates.settings.yml new file mode 100644 index 0000000000..207c746331 --- /dev/null +++ b/config/install/automatic_updates.settings.yml @@ -0,0 +1 @@ +cron: security diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml new file mode 100644 index 0000000000..b3047d9987 --- /dev/null +++ b/config/schema/automatic_updates.schema.yml @@ -0,0 +1,7 @@ +automatic_updates.settings: + type: config_object + label: 'Automatic Updates settings' + mapping: + cron: + type: string + label: 'Enable automatic updates during cron' diff --git a/src/CronUpdater.php b/src/CronUpdater.php index 19b0d26105..c9392e5af2 100644 --- a/src/CronUpdater.php +++ b/src/CronUpdater.php @@ -2,6 +2,7 @@ namespace Drupal\automatic_updates; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -15,6 +16,27 @@ use Symfony\Component\DependencyInjection\ContainerInterface; */ class CronUpdater implements ContainerInjectionInterface { + /** + * All automatic updates are disabled. + * + * @var string + */ + public const DISABLED = 'disable'; + + /** + * Only perform automatic security updates. + * + * @var string + */ + public const SECURITY = 'security'; + + /** + * All automatic updates are enabled. + * + * @var string + */ + public const ALL = 'patch'; + /** * The updater service. * @@ -22,6 +44,13 @@ class CronUpdater implements ContainerInjectionInterface { */ protected $updater; + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + /** * The logger. * @@ -34,11 +63,14 @@ class CronUpdater implements ContainerInjectionInterface { * * @param \Drupal\automatic_updates\Updater $updater * The updater service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory * The logger channel factory. */ - public function __construct(Updater $updater, LoggerChannelFactoryInterface $logger_factory) { + public function __construct(Updater $updater, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory) { $this->updater = $updater; + $this->configFactory = $config_factory; $this->logger = $logger_factory->get('automatic_updates'); } @@ -48,6 +80,7 @@ class CronUpdater implements ContainerInjectionInterface { public static function create(ContainerInterface $container) { return new static( $container->get('automatic_updates.updater'), + $container->get('config.factory'), $container->get('logger.factory') ); } @@ -56,6 +89,14 @@ class CronUpdater implements ContainerInjectionInterface { * Handles updates during cron. */ public function handleCron(): void { + $level = $this->configFactory->get('automatic_updates.settings') + ->get('cron'); + + // If automatic updates are disabled, bail out. + if ($level === static::DISABLED) { + return; + } + $recommender = new UpdateRecommender(); try { $recommended_release = $recommender->getRecommendedRelease(TRUE); @@ -76,6 +117,12 @@ class CronUpdater implements ContainerInjectionInterface { return; } + // If automatic updates are only enabled for security releases, bail out if + // the recommended release is not a security release. + if ($level === static::SECURITY && !$recommended_release->isSecurityRelease()) { + return; + } + // @todo Use the queue to add update jobs allowing jobs to span multiple // cron runs. $recommended_version = $recommended_release->getVersion(); diff --git a/tests/src/Kernel/CronUpdaterTest.php b/tests/src/Kernel/CronUpdaterTest.php new file mode 100644 index 0000000000..6de07f4bbe --- /dev/null +++ b/tests/src/Kernel/CronUpdaterTest.php @@ -0,0 +1,112 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Kernel; + +use Drupal\automatic_updates\CronUpdater; +use Drupal\Core\Form\FormState; +use Drupal\update\UpdateSettingsForm; + +/** + * @covers \Drupal\automatic_updates\CronUpdater + * @covers \automatic_updates_form_update_settings_alter + * + * @group automatic_updates + */ +class CronUpdaterTest extends AutomaticUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'automatic_updates', + 'package_manager', + ]; + + /** + * Data provider for ::testUpdaterCalled(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerUpdaterCalled(): array { + $fixture_dir = __DIR__ . '/../../fixtures/release-history'; + + return [ + 'disabled, normal release' => [ + CronUpdater::DISABLED, + "$fixture_dir/drupal.9.8.1.xml", + FALSE, + ], + 'disabled, security release' => [ + CronUpdater::DISABLED, + "$fixture_dir/drupal.9.8.1-security.xml", + FALSE, + ], + 'security only, security release' => [ + CronUpdater::SECURITY, + "$fixture_dir/drupal.9.8.1-security.xml", + TRUE, + ], + 'security only, normal release' => [ + CronUpdater::SECURITY, + "$fixture_dir/drupal.9.8.1.xml", + FALSE, + ], + 'enabled, normal release' => [ + CronUpdater::ALL, + "$fixture_dir/drupal.9.8.1.xml", + TRUE, + ], + 'enabled, security release' => [ + CronUpdater::ALL, + "$fixture_dir/drupal.9.8.1-security.xml", + TRUE, + ], + ]; + } + + /** + * Tests that the cron handler calls the updater as expected. + * + * @param string $setting + * Whether automatic updates should be enabled during cron. Possible values + * are 'disable', 'security', and 'patch'. + * @param string $release_data + * If automatic updates are enabled, the path of the fake release metadata + * that should be served when fetching information on available updates. + * @param bool $will_update + * Whether an update should be performed, given the previous two arguments. + * + * @dataProvider providerUpdaterCalled + */ + public function testUpdaterCalled(string $setting, string $release_data, bool $will_update): void { + // Our form alter does not refresh information on available updates, so + // ensure that the appropriate update data is loaded beforehand. + $this->setReleaseMetadata($release_data); + update_get_available(TRUE); + + // Submit the configuration form programmatically, to prove our alterations + // work as expected. + $form_builder = $this->container->get('form_builder'); + $form_state = new FormState(); + $form = $form_builder->buildForm(UpdateSettingsForm::class, $form_state); + // Ensure that the version ranges in the setting's description, which are + // computed dynamically, look correct. + $this->assertStringContainsString('Automatic updates are only supported for 9.8.x versions of Drupal core. Drupal 9.8 will receive security updates until 9.10.0 is released.', $form['automatic_updates_cron']['#description']); + $form_state->setValue('automatic_updates_cron', $setting); + $form_builder->submitForm(UpdateSettingsForm::class, $form_state); + + // Mock the updater so we can assert that its methods are called or bypassed + // depending on configuration. + $will_update = (int) $will_update; + $updater = $this->prophesize('\Drupal\automatic_updates\Updater'); + $updater->begin(['drupal' => '9.8.1'])->shouldBeCalledTimes($will_update); + $updater->stage()->shouldBeCalledTimes($will_update); + $updater->commit()->shouldBeCalledTimes($will_update); + $updater->clean()->shouldBeCalledTimes($will_update); + $this->container->set('automatic_updates.updater', $updater->reveal()); + + $this->container->get('cron')->run(); + } + +} -- GitLab