From 2487d9c2feae0887d75ffc5cc37e890cc079ff8b Mon Sep 17 00:00:00 2001 From: tedbow <tedbow@240860.no-reply.drupal.org> Date: Tue, 29 Mar 2022 20:28:40 +0000 Subject: [PATCH] Issue #3254755 by tedbow, kunal.sachdev, phenaproxima: Add a seperate Update recommender and update version validator for cron updates --- automatic_updates.module | 7 +- automatic_updates.services.yml | 16 ++ src/CronUpdater.php | 71 +++--- src/Form/UpdaterForm.php | 47 ++-- src/ProjectInfo.php | 106 +++++++++ src/ReleaseChooser.php | 132 ++++++++++++ src/UpdateRecommender.php | 69 ------ src/Validation/ReadinessValidationManager.php | 12 +- src/Validator/CronUpdateVersionValidator.php | 100 +++++++++ src/Validator/UpdateVersionValidator.php | 204 ++++++++++-------- src/VersionParsingTrait.php | 37 ++++ .../drupal.9.8.2-older-sec-release.xml | 102 +++++++++ .../fixtures/release-history/drupal.9.8.2.xml | 38 +++- .../Functional/ReadinessValidationTest.php | 2 +- tests/src/Functional/UpdateLockTest.php | 2 +- tests/src/Kernel/CronUpdaterTest.php | 2 +- .../ReadinessValidationManagerTest.php | 4 +- .../UpdateVersionValidatorTest.php | 86 +------- tests/src/Kernel/ReleaseChooserTest.php | 171 +++++++++++++++ tests/src/Kernel/UpdateRecommenderTest.php | 47 ---- tests/src/Unit/ProjectInfoTest.php | 194 +++++++++++++++++ 21 files changed, 1100 insertions(+), 349 deletions(-) create mode 100644 src/ProjectInfo.php create mode 100644 src/ReleaseChooser.php delete mode 100644 src/UpdateRecommender.php create mode 100644 src/Validator/CronUpdateVersionValidator.php create mode 100644 src/VersionParsingTrait.php create mode 100644 tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml create mode 100644 tests/src/Kernel/ReleaseChooserTest.php delete mode 100644 tests/src/Kernel/UpdateRecommenderTest.php create mode 100644 tests/src/Unit/ProjectInfoTest.php diff --git a/automatic_updates.module b/automatic_updates.module index b683f56218..a11765cb01 100644 --- a/automatic_updates.module +++ b/automatic_updates.module @@ -6,9 +6,9 @@ */ use Drupal\automatic_updates\BatchProcessor; +use Drupal\automatic_updates\ProjectInfo; use Drupal\Core\Routing\RouteMatchInterface; 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; @@ -141,9 +141,8 @@ 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']); + $project_info = new ProjectInfo(); + $version = ExtensionVersion::createFromVersionString($project_info->getInstalledVersion()); $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion(); // @todo In https://www.drupal.org/node/2998285 use the update XML to // determine when the installed of core will become unsupported. diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index d8d6ca1e7f..516e06545f 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -26,6 +26,7 @@ services: automatic_updates.cron_updater: class: Drupal\automatic_updates\CronUpdater arguments: + - '@automatic_updates.cron_release_chooser' - '@logger.factory' - '@config.factory' - '@package_manager.path_locator' @@ -55,6 +56,21 @@ services: - '@config.factory' tags: - { name: event_subscriber } + automatic_updates.cron_update_version_validator: + class: Drupal\automatic_updates\Validator\CronUpdateVersionValidator + arguments: + - '@string_translation' + - '@config.factory' + tags: + - { name: event_subscriber } + automatic_updates.release_chooser: + class: Drupal\automatic_updates\ReleaseChooser + arguments: + - '@automatic_updates.update_version_validator' + automatic_updates.cron_release_chooser: + class: Drupal\automatic_updates\ReleaseChooser + arguments: + - '@automatic_updates.cron_update_version_validator' automatic_updates.composer_executable_validator: class: Drupal\automatic_updates\Validator\PackageManagerReadinessCheck arguments: diff --git a/src/CronUpdater.php b/src/CronUpdater.php index 69d1fb1872..38903875c7 100644 --- a/src/CronUpdater.php +++ b/src/CronUpdater.php @@ -42,16 +42,26 @@ class CronUpdater extends Updater { */ protected $logger; + /** + * The cron release chooser service. + * + * @var \Drupal\automatic_updates\ReleaseChooser + */ + protected $releaseChooser; + /** * Constructs a CronUpdater object. * + * @param \Drupal\automatic_updates\ReleaseChooser $release_chooser + * The cron release chooser service. * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory * The logger channel factory. * @param mixed ...$arguments * Additional arguments to pass to the parent constructor. */ - public function __construct(LoggerChannelFactoryInterface $logger_factory, ...$arguments) { + public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, ...$arguments) { parent::__construct(...$arguments); + $this->releaseChooser = $release_chooser; $this->logger = $logger_factory->get('automatic_updates'); } @@ -59,50 +69,35 @@ class CronUpdater extends Updater { * 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) { + if ($this->isDisabled()) { return; } - $recommender = new UpdateRecommender(); - try { - $recommended_release = $recommender->getRecommendedRelease(TRUE); - } - catch (\Throwable $e) { - $this->logger->error($e->getMessage()); - return; - } - - // If we're already up-to-date, there's nothing else we need to do. - if ($recommended_release === NULL) { - return; + $next_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor(); + if ($next_release) { + $this->performUpdate($next_release->getVersion()); } + } - $project = $recommender->getProjectInfo(); - if (empty($project['existing_version'])) { + /** + * Performs the update. + * + * @param string $update_version + * The version to which to update. + */ + private function performUpdate(string $update_version): void { + $installed_version = (new ProjectInfo())->getInstalledVersion(); + if (empty($installed_version)) { $this->logger->error('Unable to determine the current version of Drupal core.'); 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(); - // Do the bulk of the update in its own try-catch structure, so that we can // handle any exceptions or validation errors consistently, and destroy the // stage regardless of whether the update succeeds. try { $this->begin([ - 'drupal' => $recommended_version, + 'drupal' => $update_version, ]); $this->stage(); $this->apply(); @@ -110,8 +105,8 @@ class CronUpdater extends Updater { $this->logger->info( 'Drupal core has been updated from %previous_version to %update_version', [ - '%previous_version' => $project['existing_version'], - '%update_version' => $recommended_version, + '%previous_version' => $installed_version, + '%update_version' => $update_version, ] ); } @@ -138,6 +133,16 @@ class CronUpdater extends Updater { } } + /** + * Determines if cron updates are disabled. + * + * @return bool + * TRUE if cron updates are disabled, otherwise FALSE. + */ + private function isDisabled(): bool { + return $this->configFactory->get('automatic_updates.settings')->get('cron') === static::DISABLED; + } + /** * Generates a log message from a stage validation exception. * diff --git a/src/Form/UpdaterForm.php b/src/Form/UpdaterForm.php index 7cf23e7dc1..e7d45b0d8d 100644 --- a/src/Form/UpdaterForm.php +++ b/src/Form/UpdaterForm.php @@ -4,8 +4,9 @@ namespace Drupal\automatic_updates\Form; use Drupal\automatic_updates\BatchProcessor; use Drupal\automatic_updates\Event\ReadinessCheckEvent; +use Drupal\automatic_updates\ProjectInfo; +use Drupal\automatic_updates\ReleaseChooser; use Drupal\automatic_updates\Updater; -use Drupal\automatic_updates\UpdateRecommender; use Drupal\automatic_updates\Validation\ReadinessTrait; use Drupal\Core\Batch\BatchBuilder; use Drupal\Core\Form\FormBase; @@ -19,7 +20,6 @@ use Drupal\system\SystemManager; use Drupal\update\UpdateManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\HttpFoundation\Session\SessionInterface; /** * Defines a form to update Drupal core. @@ -53,11 +53,11 @@ class UpdaterForm extends FormBase { protected $eventDispatcher; /** - * The current session. + * The release chooser service. * - * @var \Symfony\Component\HttpFoundation\Session\SessionInterface + * @var \Drupal\automatic_updates\ReleaseChooser */ - protected $session; + protected $releaseChooser; /** * Constructs a new UpdaterForm object. @@ -68,14 +68,14 @@ class UpdaterForm extends FormBase { * The updater service. * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher service. - * @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session - * The current session. + * @param \Drupal\automatic_updates\ReleaseChooser $release_chooser + * The release chooser service. */ - public function __construct(StateInterface $state, Updater $updater, EventDispatcherInterface $event_dispatcher, SessionInterface $session) { + public function __construct(StateInterface $state, Updater $updater, EventDispatcherInterface $event_dispatcher, ReleaseChooser $release_chooser) { $this->updater = $updater; $this->state = $state; $this->eventDispatcher = $event_dispatcher; - $this->session = $session; + $this->releaseChooser = $release_chooser; } /** @@ -93,7 +93,7 @@ class UpdaterForm extends FormBase { $container->get('state'), $container->get('automatic_updates.updater'), $container->get('event_dispatcher'), - $container->get('session') + $container->get('automatic_updates.release_chooser') ); } @@ -112,7 +112,7 @@ class UpdaterForm extends FormBase { // If there's a stage ID stored in the session, try to claim the stage // with it. If we succeed, then an update is already in progress, and the // current session started it, so redirect them to the confirmation form. - $stage_id = $this->session->get(BatchProcessor::STAGE_ID_SESSION_KEY); + $stage_id = $this->getRequest()->getSession()->get(BatchProcessor::STAGE_ID_SESSION_KEY); if ($stage_id) { try { $this->updater->claim($stage_id); @@ -131,10 +131,24 @@ class UpdaterForm extends FormBase { '#theme' => 'update_last_check', '#last' => $this->state->get('update.last_check', 0), ]; + $project_info = new ProjectInfo(); - $recommender = new UpdateRecommender(); try { - $recommended_release = $recommender->getRecommendedRelease(TRUE); + // @todo Until https://www.drupal.org/i/3264849 is fixed, we can only show + // one release on the form. First, try to show the latest release in the + // currently installed minor. Failing that, try to show the latest + // release in the next minor. If neither of those are available, just + // show the first available release. + $recommended_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor(); + if (!$recommended_release) { + $recommended_release = $this->releaseChooser->getLatestInNextMinor(); + if (!$recommended_release) { + // @todo Do not list an update that can't be validated in + // https://www.drupal.org/i/3271235. + $updates = $project_info->getInstallableReleases(); + $recommended_release = array_pop($updates); + } + } } catch (\RuntimeException $e) { $form['message'] = [ @@ -148,6 +162,9 @@ class UpdaterForm extends FormBase { // If we're already up-to-date, there's nothing else we need to do. if ($recommended_release === NULL) { + // @todo Link to the Available Updates report if there are other updates + // that are not supported by this module in + // https://www.drupal.org/i/3271235. $this->messenger()->addMessage('No update available'); return $form; } @@ -159,7 +176,7 @@ class UpdaterForm extends FormBase { ], ]; - $project = $recommender->getProjectInfo(); + $project = $project_info->getProjectInfo(); if (empty($project['title']) || empty($project['link'])) { throw new \UnexpectedValueException('Expected project data to have a title and link.'); } @@ -187,7 +204,7 @@ class UpdaterForm extends FormBase { 'title' => [ 'data' => $title, ], - 'installed_version' => $project['existing_version'], + 'installed_version' => $project_info->getInstalledVersion(), 'recommended_version' => [ 'data' => [ // @todo Is an inline template the right tool here? Is there an Update diff --git a/src/ProjectInfo.php b/src/ProjectInfo.php new file mode 100644 index 0000000000..27101c6bea --- /dev/null +++ b/src/ProjectInfo.php @@ -0,0 +1,106 @@ +<?php + +namespace Drupal\automatic_updates; + +use Composer\Semver\Comparator; +use Composer\Semver\Semver; +use Drupal\automatic_updates_9_3_shim\ProjectRelease; +use Drupal\update\UpdateManagerInterface; + +/** + * Defines a class for retrieving project information from Update module. + * + * @todo Allow passing a project name to handle more than Drupal core in + * https://www.drupal.org/i/3271240. + */ +class ProjectInfo { + + /** + * Returns up-to-date project information for Drupal core. + * + * @param bool $refresh + * (optional) Whether to fetch the latest information about available + * updates from drupal.org. This can be an expensive operation, so defaults + * to FALSE. + * + * @return array + * The retrieved project information for Drupal core. + * + * @throws \RuntimeException + * If data about available updates cannot be retrieved. + */ + public function getProjectInfo(bool $refresh = FALSE): array { + $available_updates = update_get_available($refresh); + $project_data = update_calculate_project_data($available_updates); + return $project_data['drupal']; + } + + /** + * Gets all releases of Drupal core to which the site can update. + * + * @param bool $refresh + * (optional) Whether to fetch the latest information about available + * updates from drupal.org. This can be an expensive operation, so defaults + * to FALSE. + * + * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease[] + * An array of possible update releases with release versions as keys. The + * releases are in descending order by version number (i.e., higher versions + * are listed first). + * + * @throws \RuntimeException + * Thrown if $refresh is TRUE and there are no available releases. + * + * @todo Remove or simplify this function in https://www.drupal.org/i/3252190. + */ + public function getInstallableReleases(bool $refresh = FALSE): array { + $project = $this->getProjectInfo($refresh); + $installed_version = $this->getInstalledVersion(); + // If we refreshed and we were able to get available releases we should + // always have at least have the current release stored. + if ($refresh && empty($project['releases'])) { + throw new \RuntimeException('There was a problem getting update information. Try again later.'); + } + // If we're already up-to-date, there's nothing else we need to do. + if ($project['status'] === UpdateManagerInterface::CURRENT) { + return []; + } + elseif (empty($project['recommended'])) { + // If we don't know what to recommend they update to, time to freak out. + throw new \LogicException('Drupal core is out of date, but the recommended version could not be determined.'); + } + $installable_releases = []; + if (Comparator::greaterThan($project['recommended'], $installed_version)) { + $release = ProjectRelease::createFromArray($project['releases'][$project['recommended']]); + $installable_releases[$release->getVersion()] = $release; + } + if (!empty($project['security updates'])) { + foreach ($project['security updates'] as $security_update) { + $release = ProjectRelease::createFromArray($security_update); + $version = $release->getVersion(); + if (Comparator::greaterThan($version, $installed_version)) { + $installable_releases[$version] = $release; + } + } + } + $sorted_versions = Semver::rsort(array_keys($installable_releases)); + return array_replace(array_flip($sorted_versions), $installable_releases); + } + + /** + * Returns the installed project version, according to the Update module. + * + * @param bool $refresh + * (optional) Whether to fetch the latest information about available + * updates from drupal.org. This can be an expensive operation, so defaults + * to FALSE. + * + * @return string + * The installed project version as known to the Update module. + */ + public function getInstalledVersion(bool $refresh = FALSE): string { + $project_data = $this->getProjectInfo($refresh); + return $project_data['existing_version']; + } + +} diff --git a/src/ReleaseChooser.php b/src/ReleaseChooser.php new file mode 100644 index 0000000000..774d0ec5f2 --- /dev/null +++ b/src/ReleaseChooser.php @@ -0,0 +1,132 @@ +<?php + +namespace Drupal\automatic_updates; + +use Composer\Semver\Semver; +use Drupal\automatic_updates\Validator\UpdateVersionValidator; +use Drupal\automatic_updates_9_3_shim\ProjectRelease; +use Drupal\Core\Extension\ExtensionVersion; + +/** + * Defines a class to choose a release of Drupal core to update to. + */ +class ReleaseChooser { + + use VersionParsingTrait; + + /** + * The version validator service. + * + * @var \Drupal\automatic_updates\Validator\UpdateVersionValidator + */ + protected $versionValidator; + + /** + * The project information fetcher. + * + * @var \Drupal\automatic_updates\ProjectInfo + */ + protected $projectInfo; + + /** + * Constructs an ReleaseChooser object. + * + * @param \Drupal\automatic_updates\Validator\UpdateVersionValidator $version_validator + * The version validator. + */ + public function __construct(UpdateVersionValidator $version_validator) { + $this->versionValidator = $version_validator; + $this->projectInfo = new ProjectInfo(); + } + + /** + * Refreshes the project information through the Update module. + * + * @return $this + * The called object. + */ + public function refresh(): self { + $this->projectInfo->getProjectInfo(TRUE); + return $this; + } + + /** + * Returns the releases that are installable. + * + * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease[] + * The releases that are installable according to the version validator + * service. + */ + protected function getInstallableReleases(): array { + return array_filter( + $this->projectInfo->getInstallableReleases(), + [$this->versionValidator, 'isValidVersion'], + ARRAY_FILTER_USE_KEY + ); + } + + /** + * Gets the most recent release in the same minor as a specified version. + * + * @param string $version + * The full semantic version number, which must include a patch version. + * + * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease|null + * The most recent release in the minor if available, otherwise NULL. + * + * @throws \InvalidArgumentException + * If the given semantic version number does not contain a patch version. + */ + protected function getMostRecentReleaseInMinor(string $version): ?ProjectRelease { + if (static::getPatchVersion($version) === NULL) { + throw new \InvalidArgumentException("The version number $version does not contain a patch version"); + } + $releases = $this->getInstallableReleases(); + foreach ($releases as $release) { + if (Semver::satisfies($release->getVersion(), "~$version")) { + return $release; + } + } + return NULL; + } + + /** + * Gets the installed version of Drupal core. + * + * @return string + * The installed version of Drupal core. + */ + protected function getInstalledVersion(): string { + return $this->projectInfo->getInstalledVersion(); + } + + /** + * Gets the latest release in the currently installed minor. + * + * This will only return a release if it passes the ::isValidVersion() method + * of the version validator service injected into this class. + * + * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease|null + * The latest release in the currently installed minor, if any, otherwise + * NULL. + */ + public function getLatestInInstalledMinor(): ?ProjectRelease { + return $this->getMostRecentReleaseInMinor($this->getInstalledVersion()); + } + + /** + * Gets the latest release in the next minor. + * + * This will only return a release if it passes the ::isValidVersion() method + * of the version validator service injected into this class. + * + * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease|null + * The latest release in the next minor, if any, otherwise NULL. + */ + public function getLatestInNextMinor(): ?ProjectRelease { + $installed_version = ExtensionVersion::createFromVersionString($this->getInstalledVersion()); + $next_minor = $installed_version->getMajorVersion() . '.' . (((int) $installed_version->getMinorVersion()) + 1) . '.0'; + return $this->getMostRecentReleaseInMinor($next_minor); + } + +} diff --git a/src/UpdateRecommender.php b/src/UpdateRecommender.php deleted file mode 100644 index 7e6a2788eb..0000000000 --- a/src/UpdateRecommender.php +++ /dev/null @@ -1,69 +0,0 @@ -<?php - -namespace Drupal\automatic_updates; - -use Drupal\automatic_updates_9_3_shim\ProjectRelease; -use Drupal\update\UpdateManagerInterface; - -/** - * Determines the recommended release of Drupal core to update to. - */ -class UpdateRecommender { - - /** - * Returns up-to-date project information for Drupal core. - * - * @param bool $refresh - * (optional) Whether to fetch the latest information about available - * updates from drupal.org. This can be an expensive operation, so defaults - * to FALSE. - * - * @return array - * The retrieved project information for Drupal core. - * - * @throws \RuntimeException - * If data about available updates cannot be retrieved. - */ - public function getProjectInfo(bool $refresh = FALSE): array { - $available_updates = update_get_available($refresh); - if (empty($available_updates)) { - throw new \RuntimeException('There was a problem getting update information. Try again later.'); - } - - $project_data = update_calculate_project_data($available_updates); - return $project_data['drupal']; - } - - /** - * Returns the recommended release of Drupal core. - * - * @param bool $refresh - * (optional) Whether to fetch the latest information about available - * updates from drupal.org. This can be an expensive operation, so defaults - * to FALSE. - * - * @return \Drupal\automatic_updates_9_3_shim\ProjectRelease|null - * A value object with information about the recommended release, or NULL - * if Drupal core is already up-to-date. - * - * @throws \LogicException - * If Drupal core is out of date and the recommended version of cannot be - * determined. - */ - public function getRecommendedRelease(bool $refresh = FALSE): ?ProjectRelease { - $project = $this->getProjectInfo($refresh); - - // If we're already up-to-date, there's nothing else we need to do. - if ($project['status'] === UpdateManagerInterface::CURRENT) { - return NULL; - } - // If we don't know what to recommend they update to, time to freak out. - elseif (empty($project['recommended'])) { - throw new \LogicException('Drupal core is out of date, but the recommended version could not be determined.'); - } - - $recommended_version = $project['recommended']; - return ProjectRelease::createFromArray($project['releases'][$recommended_version]); - } - -} diff --git a/src/Validation/ReadinessValidationManager.php b/src/Validation/ReadinessValidationManager.php index 0622c9defb..15ab0b3376 100644 --- a/src/Validation/ReadinessValidationManager.php +++ b/src/Validation/ReadinessValidationManager.php @@ -4,8 +4,8 @@ namespace Drupal\automatic_updates\Validation; use Drupal\automatic_updates\CronUpdater; use Drupal\automatic_updates\Event\ReadinessCheckEvent; +use Drupal\automatic_updates\ProjectInfo; use Drupal\automatic_updates\Updater; -use Drupal\automatic_updates\UpdateRecommender; use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface; @@ -102,20 +102,18 @@ class ReadinessValidationManager implements EventSubscriberInterface { * @return $this */ public function run(): self { - $recommender = new UpdateRecommender(); - $release = $recommender->getRecommendedRelease(TRUE); // If updates will run during cron, use the cron updater service provided by // this module. This will allow subscribers to ReadinessCheckEvent to run // specific validation for conditions that only affect cron updates. - if ($this->config->get('automatic_updates.settings')->get('cron') == CronUpdater::DISABLED) { + if ($this->config->get('automatic_updates.settings')->get('cron') === CronUpdater::DISABLED) { $stage = $this->updater; } else { $stage = $this->cronUpdater; } - - $project_versions = $release ? ['drupal' => $release->getVersion()] : []; - $event = new ReadinessCheckEvent($stage, $project_versions); + $event = new ReadinessCheckEvent($stage); + // Version validators will need up-to-date project info. + (new ProjectInfo())->getProjectInfo(TRUE); $this->eventDispatcher->dispatch($event); $results = $event->getResults(); $this->keyValueExpirable->setWithExpire( diff --git a/src/Validator/CronUpdateVersionValidator.php b/src/Validator/CronUpdateVersionValidator.php new file mode 100644 index 0000000000..f9070f8610 --- /dev/null +++ b/src/Validator/CronUpdateVersionValidator.php @@ -0,0 +1,100 @@ +<?php + +namespace Drupal\automatic_updates\Validator; + +use Drupal\automatic_updates\CronUpdater; +use Drupal\automatic_updates\ProjectInfo; +use Drupal\automatic_updates\VersionParsingTrait; +use Drupal\Core\Extension\ExtensionVersion; +use Drupal\package_manager\Stage; +use Drupal\package_manager\ValidationResult; + +/** + * Validates the target version of Drupal core before a cron update. + * + * @internal + * This class is an internal part of the module's cron update handling and + * should not be used by external code. + */ +final class CronUpdateVersionValidator extends UpdateVersionValidator { + + use VersionParsingTrait; + + /** + * {@inheritdoc} + */ + protected static function isStageSupported(Stage $stage): bool { + return $stage instanceof CronUpdater; + } + + /** + * {@inheritdoc} + */ + public function getValidationResult(string $to_version_string): ?ValidationResult { + if ($result = parent::getValidationResult($to_version_string)) { + return $result; + } + $from_version_string = $this->getCoreVersion(); + $to_version = ExtensionVersion::createFromVersionString($to_version_string); + $from_version = ExtensionVersion::createFromVersionString($from_version_string); + $variables = [ + '@to_version' => $to_version_string, + '@from_version' => $from_version_string, + ]; + // @todo Return multiple validation messages and summary in + // https://www.drupal.org/project/automatic_updates/issues/3272068. + // Validate that both the from and to versions are stable releases. + if ($from_version->getVersionExtra()) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, because Automatic Updates only supports updating from stable versions during cron.', $variables), + ]); + } + if ($to_version->getVersionExtra()) { + // Because we do not support updating to a new minor version during + // cron it is probably impossible to update from a stable version to + // a unstable/pre-release version, but we should check this condition + // just in case. + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron to the recommended version, @to_version, because Automatic Updates only supports updating to stable versions during cron.', $variables), + ]); + } + + if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported during cron.', $variables), + ]); + } + + // Only updating to the next patch release is supported during cron. + $supported_patch_version = $from_version->getMajorVersion() . '.' . $from_version->getMinorVersion() . '.' . (((int) static::getPatchVersion($from_version_string)) + 1); + if ($to_version_string !== $supported_patch_version) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because Automatic Updates only supports 1 patch version update during cron.', $variables), + ]); + } + + // If both the from and to version numbers are valid check if the current + // settings only allow security updates during cron and if so ensure the + // update release is a security release. + $level = $this->configFactory->get('automatic_updates.settings')->get('cron'); + if ($level === CronUpdater::SECURITY) { + $releases = (new ProjectInfo())->getInstallableReleases(); + // @todo Remove this check and add validation to + // \Drupal\automatic_updates\Validator\UpdateVersionValidator::getValidationResult() + // to ensure the update release is always secure and supported in + // https://www.drupal.org/i/3271468. + if (!isset($releases[$to_version_string])) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a valid release.', $variables), + ]); + } + if (!$releases[$to_version_string]->isSecurityRelease()) { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a security release.', $variables), + ]); + } + } + return NULL; + } + +} diff --git a/src/Validator/UpdateVersionValidator.php b/src/Validator/UpdateVersionValidator.php index d867c393b7..50934ec0f6 100644 --- a/src/Validator/UpdateVersionValidator.php +++ b/src/Validator/UpdateVersionValidator.php @@ -2,20 +2,28 @@ namespace Drupal\automatic_updates\Validator; -use Composer\Semver\Semver; +use Composer\Semver\Comparator; use Drupal\automatic_updates\CronUpdater; use Drupal\automatic_updates\Event\ReadinessCheckEvent; +use Drupal\automatic_updates\ProjectInfo; use Drupal\automatic_updates\Updater; use Drupal\Core\Config\ConfigFactoryInterface; -use Drupal\package_manager\Event\PreCreateEvent; -use Drupal\package_manager\Event\PreOperationStageEvent; use Drupal\Core\Extension\ExtensionVersion; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslationInterface; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Stage; +use Drupal\package_manager\ValidationResult; use Symfony\Component\EventDispatcher\EventSubscriberInterface; /** * Validates that core updates are within a supported version range. + * + * @internal + * This class is an internal part of the module's update handling and + * should not be used by external code. */ class UpdateVersionValidator implements EventSubscriberInterface { @@ -48,12 +56,7 @@ class UpdateVersionValidator implements EventSubscriberInterface { * The running core version as known to the Update module. */ protected function getCoreVersion(): string { - // We need to call these functions separately, because - // update_get_available() will include the file that contains - // update_calculate_project_data(). - $available_updates = update_get_available(); - $available_updates = update_calculate_project_data($available_updates); - return $available_updates['drupal']['existing_version']; + return (new ProjectInfo())->getInstalledVersion(); } /** @@ -63,122 +66,141 @@ class UpdateVersionValidator implements EventSubscriberInterface { * The event object. */ public function checkUpdateVersion(PreOperationStageEvent $event): void { - $stage = $event->getStage(); - // We only want to do this check if the stage belongs to Automatic Updates. - if (!$stage instanceof Updater) { + if (!static::isStageSupported($event->getStage())) { return; } + if ($to_version = $this->getUpdateVersion($event)) { + if ($result = $this->getValidationResult($to_version)) { + $event->addError($result->getMessages(), $result->getSummary()); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'checkUpdateVersion', + ReadinessCheckEvent::class => 'checkUpdateVersion', + ]; + } + /** + * Gets the update version. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event. + * + * @return string|null + * The version that the site will update to if any, otherwise NULL. + */ + protected function getUpdateVersion(StageEvent $event): ?string { + /** @var \Drupal\automatic_updates\Updater $updater */ + $updater = $event->getStage(); if ($event instanceof ReadinessCheckEvent) { $package_versions = $event->getPackageVersions(); - // During readiness checks, we might not know the desired package - // versions, which means there's nothing to validate. - if (empty($package_versions)) { - return; - } } else { // If the stage has begun its life cycle, we expect it knows the desired // package versions. - $package_versions = $stage->getPackageVersions()['production']; + $package_versions = $updater->getPackageVersions()['production']; + } + if ($package_versions) { + // All the core packages will be updated to the same version, so it + // doesn't matter which specific package we're looking at. + $core_package_name = key($updater->getActiveComposer()->getCorePackages()); + return $package_versions[$core_package_name]; + } + else { + // During readiness checks we might not have a version to update to. Check + // if there are any possible updates and add a message about why we cannot + // update to that version. + // @todo Remove this code in https://www.drupal.org/i/3272326 when we add + // add a validator that will warn if cron updates will no longer work + // because the site is more than 1 patch release behind. + $project_info = new ProjectInfo(); + if ($possible_releases = $project_info->getInstallableReleases()) { + $possible_release = array_pop($possible_releases); + return $possible_release->getVersion(); + } } + return NULL; + } + /** + * Determines if a version is valid. + * + * @param string $version + * The version string. + * + * @return bool + * TRUE if the version is valid (i.e., the site can update to it), otherwise + * FALSE. + */ + public function isValidVersion(string $version): bool { + return empty($this->getValidationResult($version)); + } + + /** + * Validates if an update to a specific version is allowed. + * + * @param string $to_version_string + * The version to update to. + * + * @return \Drupal\package_manager\ValidationResult|null + * NULL if the update is allowed, otherwise returns a validation result with + * the reason why the update is not allowed. + */ + protected function getValidationResult(string $to_version_string): ?ValidationResult { $from_version_string = $this->getCoreVersion(); - $from_version = ExtensionVersion::createFromVersionString($from_version_string); - // All the core packages will be updated to the same version, so it doesn't - // matter which specific package we're looking at. - $core_package_name = key($stage->getActiveComposer()->getCorePackages()); - $to_version_string = $package_versions[$core_package_name]; - $to_version = ExtensionVersion::createFromVersionString($to_version_string); $variables = [ '@to_version' => $to_version_string, '@from_version' => $from_version_string, ]; - $from_version_extra = $from_version->getVersionExtra(); - $to_version_extra = $to_version->getVersionExtra(); - if (Semver::satisfies($to_version_string, "< $from_version_string")) { - $event->addError([ - $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', $variables), + $from_version = ExtensionVersion::createFromVersionString($from_version_string); + // @todo Return multiple validation messages and summary in + // https://www.drupal.org/project/automatic_updates/issues/3272068. + if ($from_version->getVersionExtra() === 'dev') { + return ValidationResult::createError([ + $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from a dev version to any other version are not supported.', $variables), ]); } - elseif ($from_version_extra === 'dev') { - $event->addError([ - $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from a dev version to any other version are not supported.', $variables), + if (Comparator::lessThan($to_version_string, $from_version_string)) { + return ValidationResult::createError([ + $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', $variables), ]); } - elseif ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) { - $event->addError([ + $to_version = ExtensionVersion::createFromVersionString($to_version_string); + if ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) { + return ValidationResult::createError([ $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one major version to another are not supported.', $variables), ]); } - elseif ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) { + if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) { if (!$this->configFactory->get('automatic_updates.settings')->get('allow_core_minor_updates')) { - $event->addError([ + return ValidationResult::createError([ $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported.', $variables), ]); } - elseif ($stage instanceof CronUpdater) { - $event->addError([ - $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported during cron.', $variables), - ]); - } - } - elseif ($stage instanceof CronUpdater) { - if ($from_version_extra || $to_version_extra) { - if ($from_version_extra) { - $messages[] = $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, because Automatic Updates only supports updating from stable versions during cron.', $variables); - $event->addError($messages); - } - if ($to_version_extra) { - // Because we do not support updating to a new minor version during - // cron it is probably impossible to update from a stable version to - // a unstable/pre-release version, but we should check this condition - // just in case. - $messages[] = $this->t('Drupal cannot be automatically updated during cron to the recommended version, @to_version, because Automatic Updates only supports updating to stable versions during cron.', $variables); - $event->addError($messages); - } - } - else { - $to_patch_version = (int) $this->getPatchVersion($to_version_string); - $from_patch_version = (int) $this->getPatchVersion($from_version_string); - if ($from_patch_version + 1 !== $to_patch_version) { - $messages[] = $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because Automatic Updates only supports 1 patch version update during cron.', $variables); - $event->addError($messages); - } - } } + return NULL; } /** - * {@inheritdoc} - */ - public static function getSubscribedEvents() { - return [ - PreCreateEvent::class => 'checkUpdateVersion', - ReadinessCheckEvent::class => 'checkUpdateVersion', - ]; - } - - /** - * Gets the patch number for a version string. + * Determines if a stage is supported by this validator. * - * @todo Move this method to \Drupal\Core\Extension\ExtensionVersion in - * https://www.drupal.org/i/3261744. + * @param \Drupal\package_manager\Stage $stage + * The stage to check. * - * @param string $version_string - * The version string. - * - * @return string - * The patch number. + * @return bool + * TRUE if the stage is supported by this validator, otherwise FALSE. */ - private function getPatchVersion(string $version_string): string { - $version_extra = ExtensionVersion::createFromVersionString($version_string) - ->getVersionExtra(); - if ($version_extra) { - $version_string = str_replace("-$version_extra", '', $version_string); - } - $version_parts = explode('.', $version_string); - return $version_parts[2]; + protected static function isStageSupported(Stage $stage): bool { + // We only want to do this check if the stage belongs to Automatic Updates, + // and it is not a cron update. + // @see \Drupal\automatic_updates\Validator\CronUpdateVersionValidator + return $stage instanceof Updater && !$stage instanceof CronUpdater; } } diff --git a/src/VersionParsingTrait.php b/src/VersionParsingTrait.php new file mode 100644 index 0000000000..c8d52f8e25 --- /dev/null +++ b/src/VersionParsingTrait.php @@ -0,0 +1,37 @@ +<?php + +namespace Drupal\automatic_updates; + +use Drupal\Core\Extension\ExtensionVersion; + +/** + * Common function for parsing version traits. + * + * @internal + * This trait may be removed in patch or minor versions. + */ +trait VersionParsingTrait { + + /** + * Gets the patch number from a version string. + * + * @todo Move this method to \Drupal\Core\Extension\ExtensionVersion in + * https://www.drupal.org/i/3261744. + * + * @param string $version_string + * The version string. + * + * @return string|null + * The patch number if available, otherwise NULL. + */ + protected static function getPatchVersion(string $version_string): ?string { + $version_extra = ExtensionVersion::createFromVersionString($version_string) + ->getVersionExtra(); + if ($version_extra) { + $version_string = str_replace("-$version_extra", '', $version_string); + } + $version_parts = explode('.', $version_string); + return count($version_parts) === 3 ? $version_parts[2] : NULL; + } + +} diff --git a/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml b/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml new file mode 100644 index 0000000000..cc65b78a69 --- /dev/null +++ b/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml @@ -0,0 +1,102 @@ +<?xml version="1.0" encoding="utf-8"?> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Drupal</title> +<short_name>drupal</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>9.7.,9.8.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/drupal</link> + <terms> + <term><name>Projects</name><value>Drupal project</value></term> + </terms> +<releases> + <release> + <name>Drupal 9.8.2</name> + <version>9.8.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-2-release</release_link> + <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + <term><name>Release type</name><value>Security update</value></term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + <term><name>Release type</name><value>Insecure</value></term> + </terms> + </release> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + <term><name>Release type</name><value>Security update</value></term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + <term><name>Release type</name><value>Insecure</value></term> + </terms> + </release> + <release> + <name>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/tests/fixtures/release-history/drupal.9.8.2.xml b/tests/fixtures/release-history/drupal.9.8.2.xml index 7bb2c1a112..2de1077e5e 100644 --- a/tests/fixtures/release-history/drupal.9.8.2.xml +++ b/tests/fixtures/release-history/drupal.9.8.2.xml @@ -3,7 +3,7 @@ <title>Drupal</title> <short_name>drupal</short_name> <dc:creator>Drupal</dc:creator> -<supported_branches>9.8.</supported_branches> +<supported_branches>9.7.,9.8.</supported_branches> <project_status>published</project_status> <link>http://example.com/project/drupal</link> <terms> @@ -58,5 +58,41 @@ <term><name>Release type</name><value>Bug fixes</value></term> </terms> </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> </releases> </project> diff --git a/tests/src/Functional/ReadinessValidationTest.php b/tests/src/Functional/ReadinessValidationTest.php index c5b4fe525e..96eab4ae97 100644 --- a/tests/src/Functional/ReadinessValidationTest.php +++ b/tests/src/Functional/ReadinessValidationTest.php @@ -54,7 +54,7 @@ class ReadinessValidationTest extends AutomaticUpdatesFunctionalTestBase { */ protected function setUp(): void { parent::setUp(); - $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml'); + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml'); $this->setCoreVersion('9.8.1'); $this->reportViewerUser = $this->createUser([ diff --git a/tests/src/Functional/UpdateLockTest.php b/tests/src/Functional/UpdateLockTest.php index 1fbf8532a6..2c41af418d 100644 --- a/tests/src/Functional/UpdateLockTest.php +++ b/tests/src/Functional/UpdateLockTest.php @@ -56,7 +56,7 @@ class UpdateLockTest extends AutomaticUpdatesFunctionalTestBase { $this->checkForMetaRefresh(); $this->assertUpdateReady('9.8.1'); $assert_session->buttonExists('Continue'); - $url = parse_url($this->getSession()->getCurrentUrl(), PHP_URL_PATH); + $url = $this->getSession()->getCurrentUrl(); // Another user cannot show up and try to start an update, since the other // user already started one. diff --git a/tests/src/Kernel/CronUpdaterTest.php b/tests/src/Kernel/CronUpdaterTest.php index 4c07670396..e9a56e7d60 100644 --- a/tests/src/Kernel/CronUpdaterTest.php +++ b/tests/src/Kernel/CronUpdaterTest.php @@ -110,7 +110,7 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase { 'enabled, normal release' => [ CronUpdater::ALL, "$fixture_dir/drupal.9.8.2.xml", - TRUE, + FALSE, ], 'enabled, security release' => [ CronUpdater::ALL, diff --git a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php index 9cf626c750..c9a09cb821 100644 --- a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php +++ b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php @@ -30,6 +30,7 @@ class ReadinessValidationManagerTest extends AutomaticUpdatesKernelTestBase { */ protected function setUp(): void { parent::setUp(); + $this->setCoreVersion('9.8.2'); $this->installEntitySchema('user'); $this->installSchema('user', ['users_data']); $this->createTestValidationResults(); @@ -222,6 +223,7 @@ class ReadinessValidationManagerTest extends AutomaticUpdatesKernelTestBase { ->install(['automatic_updates']); // Ensure there's a simulated core release to update to. + $this->setCoreVersion('9.8.1'); $this->setReleaseMetadata(__DIR__ . '/../../../fixtures/release-history/drupal.9.8.2.xml'); // The readiness checker should raise a warning, so that the update is not @@ -246,7 +248,7 @@ class ReadinessValidationManagerTest extends AutomaticUpdatesKernelTestBase { /** @var \Drupal\automatic_updates\Updater $updater */ $updater = $this->container->get('automatic_updates.updater'); - $updater->begin(['drupal' => '9.8.1']); + $updater->begin(['drupal' => '9.8.2']); $updater->stage(); $updater->apply(); $updater->destroy(); diff --git a/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php b/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php index 45cabcfb6b..720f27707b 100644 --- a/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php +++ b/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php @@ -3,11 +3,8 @@ namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation; use Drupal\automatic_updates\CronUpdater; -use Drupal\Core\Logger\RfcLogLevel; -use Drupal\package_manager\Exception\StageValidationException; use Drupal\package_manager\ValidationResult; use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase; -use Drupal\Tests\automatic_updates\Kernel\TestCronUpdater; use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait; use Psr\Log\Test\TestLogger; @@ -167,11 +164,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase { // the update shouldn't have been started. elseif ($expected_results) { $this->assertUpdateStagedTimes(0); - - // An exception exactly like this one should have been thrown by - // CronUpdater::dispatch(), and subsequently caught, formatted as HTML, - // and logged. - $this->assertErrorsWereLogged($expected_results); } // If cron updates are enabled and no validation errors were expected, the // update should have started and nothing should have been logged. @@ -197,8 +189,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase { CronUpdater::DISABLED, [], ], - // The latest release is two patch releases ahead, so the update should be - // blocked even though the cron configuration allows it. 'security only' => [ CronUpdater::SECURITY, [$update_disallowed], @@ -230,15 +220,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase { $this->assertCheckerResultsFromManager($expected_results, TRUE); $this->container->get('cron')->run(); $this->assertUpdateStagedTimes(0); - - // If cron updates are enabled for all patch releases, the error should have - // been raised and logged. - if ($cron_setting === CronUpdater::ALL) { - $this->assertErrorsWereLogged($expected_results); - } - else { - $this->assertArrayNotHasKey(RfcLogLevel::ERROR, $this->logger->recordsByLevel); - } } /** @@ -280,7 +261,13 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase { $this->config('automatic_updates.settings') ->set('cron', $cron_setting) ->save(); - $this->assertCheckerResultsFromManager([], TRUE); + if ($cron_setting === CronUpdater::SECURITY) { + $expected_result = ValidationResult::createError(['Drupal cannot be automatically updated during cron from its current version, 9.8.1, to the recommended version, 9.8.2, because 9.8.2 is not a security release.']); + $this->assertCheckerResultsFromManager([$expected_result], TRUE); + } + else { + $this->assertCheckerResultsFromManager([], TRUE); + } $this->container->get('cron')->run(); $this->assertUpdateStagedTimes((int) $will_update); } @@ -298,9 +285,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase { $dev_current_version = ValidationResult::createError([ 'Drupal cannot be automatically updated from its current version, 9.8.0-dev, to the recommended version, 9.8.2, because automatic updates from a dev version to any other version are not supported.', ]); - $newer_current_version = ValidationResult::createError([ - 'Update version 9.8.2 is lower than 9.8.3, downgrading is not supported.', - ]); $different_major_version = ValidationResult::createError([ 'Drupal cannot be automatically updated from its current version, 8.9.1, to the recommended version, 9.8.2, because automatic updates from one major version to another are not supported.', ]); @@ -313,81 +297,46 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase { // the validation will be run with the regular updater, not the cron // updater. [], - [], ], 'unstable current version, security updates allowed' => [ CronUpdater::SECURITY, '9.8.0-alpha1', [$unstable_current_version], - // The update will not run because the latest release is not a security - // release, so nothing should be logged. - [], ], 'unstable current version, all updates allowed' => [ CronUpdater::ALL, '9.8.0-alpha1', [$unstable_current_version], - [$unstable_current_version], ], 'dev current version, cron disabled' => [ CronUpdater::DISABLED, '9.8.0-dev', [$dev_current_version], - [], ], 'dev current version, security updates allowed' => [ CronUpdater::SECURITY, '9.8.0-dev', [$dev_current_version], - // The update will not run because the latest release is not a security - // release, so nothing should be logged. - [], ], 'dev current version, all updates allowed' => [ CronUpdater::ALL, '9.8.0-dev', [$dev_current_version], - [$dev_current_version], - ], - 'newer current version, cron disabled' => [ - CronUpdater::DISABLED, - '9.8.3', - [$newer_current_version], - [], - ], - 'newer current version, security updates allowed' => [ - CronUpdater::SECURITY, - '9.8.3', - [$newer_current_version], - // The update will not run because the latest release is not a security - // release, so nothing should be logged. - [], - ], - 'newer current version, all updates allowed' => [ - CronUpdater::ALL, - '9.8.3', - [$newer_current_version], - [$newer_current_version], ], 'different current major, cron disabled' => [ CronUpdater::DISABLED, '8.9.1', [$different_major_version], - [], ], 'different current major, security updates allowed' => [ CronUpdater::SECURITY, '8.9.1', [$different_major_version], - // The update will not run because the latest release is not a security - // release, so nothing should be logged. - [], ], 'different current major, all updates allowed' => [ CronUpdater::ALL, '8.9.1', [$different_major_version], - [$different_major_version], ], ]; } @@ -402,12 +351,10 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase { * @param \Drupal\package_manager\ValidationResult[] $expected_results * The validation results, if any, that should be flagged during readiness * checks. - * @param \Drupal\package_manager\ValidationResult[] $logged_results - * The validation results, if any, that should be logged when cron is run. * * @dataProvider providerInvalidCronUpdate */ - public function testInvalidCronUpdate(string $cron_setting, string $current_core_version, array $expected_results, array $logged_results): void { + public function testInvalidCronUpdate(string $cron_setting, string $current_core_version, array $expected_results): void { $this->setCoreVersion($current_core_version); $this->config('automatic_updates.settings') ->set('cron', $cron_setting) @@ -423,23 +370,6 @@ class UpdateVersionValidatorTest extends AutomaticUpdatesKernelTestBase { // created (in which case, we expect the errors to be logged). $this->container->get('cron')->run(); $this->assertUpdateStagedTimes(0); - if ($logged_results) { - $this->assertErrorsWereLogged($logged_results); - } - } - - /** - * Asserts that validation errors were logged during a cron update. - * - * @param \Drupal\package_manager\ValidationResult[] $results - * The validation errors should have been logged. - */ - private function assertErrorsWereLogged(array $results): void { - $exception = new StageValidationException($results, 'Unable to complete the update because of errors.'); - // The exception will be formatted in a specific, predictable way. - // @see \Drupal\Tests\automatic_updates\Kernel\CronUpdaterTest::testErrors() - $message = TestCronUpdater::formatValidationException($exception); - $this->assertTrue($this->logger->hasRecord($message, RfcLogLevel::ERROR)); } } diff --git a/tests/src/Kernel/ReleaseChooserTest.php b/tests/src/Kernel/ReleaseChooserTest.php new file mode 100644 index 0000000000..dac21432ae --- /dev/null +++ b/tests/src/Kernel/ReleaseChooserTest.php @@ -0,0 +1,171 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Kernel; + +use Drupal\automatic_updates_9_3_shim\ProjectRelease; + +/** + * @coversDefaultClass \Drupal\automatic_updates\ReleaseChooser + * + * @group automatic_updates + */ +class ReleaseChooserTest extends AutomaticUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['automatic_updates']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2-older-sec-release.xml'); + + } + + /** + * Data provider for testReleases(). + * + * @return array[] + * The test cases. + */ + public function providerReleases(): array { + return [ + 'installed 9.8.0, no minor support' => [ + 'chooser' => 'automatic_updates.release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.2', + 'next_minor' => NULL, + ], + 'installed 9.8.0, minor support' => [ + 'chooser' => 'automatic_updates.release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.2', + 'next_minor' => NULL, + ], + 'installed 9.7.0, no minor support' => [ + 'chooser' => 'automatic_updates.release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => NULL, + ], + 'installed 9.7.0, minor support' => [ + 'chooser' => 'automatic_updates.release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => '9.8.2', + ], + 'installed 9.7.2, no minor support' => [ + 'chooser' => 'automatic_updates.release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => NULL, + ], + 'installed 9.7.2, minor support' => [ + 'chooser' => 'automatic_updates.release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => '9.8.2', + ], + 'cron, installed 9.8.0, no minor support' => [ + 'chooser' => 'automatic_updates.cron_release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.8.0, minor support' => [ + 'chooser' => 'automatic_updates.cron_release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.8.0', + 'current_minor' => '9.8.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.7.0, no minor support' => [ + 'chooser' => 'automatic_updates.cron_release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.7.0, minor support' => [ + 'chooser' => 'automatic_updates.cron_release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.7.0', + 'current_minor' => '9.7.1', + 'next_minor' => NULL, + ], + 'cron, installed 9.7.2, no minor support' => [ + 'chooser' => 'automatic_updates.cron_release_chooser', + 'minor_support' => FALSE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => NULL, + ], + 'cron, installed 9.7.2, minor support' => [ + 'chooser' => 'automatic_updates.cron_release_chooser', + 'minor_support' => TRUE, + 'installed_version' => '9.7.2', + 'current_minor' => NULL, + 'next_minor' => NULL, + ], + ]; + } + + /** + * Tests fetching the recommended release when an update is available. + * + * @param string $chooser_service + * The ID of release chooser service to use. + * @param bool $minor_support + * Whether updates to the next minor will be allowed. + * @param string $installed_version + * The installed version of Drupal core. + * @param string|null $current_minor + * The expected release in the currently installed minor or NULL if none is + * available. + * @param string|null $next_minor + * The expected release in the next minor or NULL if none is available. + * + * @dataProvider providerReleases + * + * @covers ::getLatestInInstalledMinor + * @covers ::getLatestInNextMinor + */ + public function testReleases(string $chooser_service, bool $minor_support, string $installed_version, ?string $current_minor, ?string $next_minor): void { + $this->setCoreVersion($installed_version); + $this->config('automatic_updates.settings')->set('allow_core_minor_updates', $minor_support)->save(); + /** @var \Drupal\automatic_updates\ReleaseChooser $chooser */ + $chooser = $this->container->get($chooser_service); + $chooser->refresh(); + $this->assertReleaseVersion($current_minor, $chooser->getLatestInInstalledMinor()); + $this->assertReleaseVersion($next_minor, $chooser->getLatestInNextMinor()); + } + + /** + * Asserts that a project release matches a version number. + * + * @param string|null $version + * The version to check, or NULL if no version expected. + * @param \Drupal\automatic_updates_9_3_shim\ProjectRelease|null $release + * The release to check, or NULL if no release is expected. + */ + private function assertReleaseVersion(?string $version, ?ProjectRelease $release) { + if (is_null($version)) { + $this->assertNull($release); + } + else { + $this->assertNotEmpty($release); + $this->assertSame($version, $release->getVersion()); + } + } + +} diff --git a/tests/src/Kernel/UpdateRecommenderTest.php b/tests/src/Kernel/UpdateRecommenderTest.php deleted file mode 100644 index be699afe00..0000000000 --- a/tests/src/Kernel/UpdateRecommenderTest.php +++ /dev/null @@ -1,47 +0,0 @@ -<?php - -namespace Drupal\Tests\automatic_updates\Kernel; - -use Drupal\automatic_updates\UpdateRecommender; - -/** - * @covers \Drupal\automatic_updates\UpdateRecommender - * - * @group automatic_updates - */ -class UpdateRecommenderTest extends AutomaticUpdatesKernelTestBase { - - /** - * {@inheritdoc} - */ - protected static $modules = [ - 'automatic_updates', - 'package_manager', - ]; - - /** - * Tests fetching the recommended release when an update is available. - */ - public function testUpdateAvailable(): void { - $recommender = new UpdateRecommender(); - $recommended_release = $recommender->getRecommendedRelease(TRUE); - $this->assertNotEmpty($recommended_release); - $this->assertSame('9.8.2', $recommended_release->getVersion()); - // Getting the recommended release again should not trigger another request. - $this->assertNotEmpty($recommender->getRecommendedRelease()); - } - - /** - * Tests fetching the recommended release when there is no update available. - */ - public function testNoUpdateAvailable(): void { - $this->setCoreVersion('9.8.2'); - - $recommender = new UpdateRecommender(); - $recommended_release = $recommender->getRecommendedRelease(TRUE); - $this->assertNull($recommended_release); - // Getting the recommended release again should not trigger another request. - $this->assertNull($recommender->getRecommendedRelease()); - } - -} diff --git a/tests/src/Unit/ProjectInfoTest.php b/tests/src/Unit/ProjectInfoTest.php new file mode 100644 index 0000000000..0ba190765f --- /dev/null +++ b/tests/src/Unit/ProjectInfoTest.php @@ -0,0 +1,194 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Unit; + +use Drupal\automatic_updates\ProjectInfo; +use Drupal\automatic_updates_9_3_shim\ProjectRelease; +use Drupal\Tests\UnitTestCase; +use Drupal\update\UpdateManagerInterface; + +/** + * @coversDefaultClass \Drupal\automatic_updates\ProjectInfo + * + * @group automatic_updates + */ +class ProjectInfoTest extends UnitTestCase { + + /** + * Creates release data for testing. + * + * @return string[][] + * The release information. + */ + private static function createTestReleases(): array { + $versions = ['8.2.5', '8.2.4', '8.2.3', '8.2.3-alpha']; + foreach ($versions as $version) { + $release_arrays[$version] = [ + 'status' => 'published', + 'version' => $version, + 'release_link' => "https://example.drupal.org/project/drupal/releases/$version", + ]; + } + return $release_arrays; + } + + /** + * Data provider for testGetInstallableReleases(). + * + * @return array[][] + * The test cases. + */ + public function providerGetInstallableReleases(): array { + $release_arrays = static::createTestReleases(); + foreach ($release_arrays as $version => $release_array) { + $release_objects[$version] = ProjectRelease::createFromArray($release_array); + } + return [ + 'current' => [ + [ + 'status' => UpdateManagerInterface::CURRENT, + 'existing_version' => '1.2.3', + ], + [], + ], + '1 release' => [ + [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '8.2.4', + 'recommended' => '8.2.5', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + ], + [ + '8.2.5' => $release_objects['8.2.5'], + ], + ], + '1 releases, also security' => [ + [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '8.2.4', + 'recommended' => '8.2.5', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + 'security updates' => [ + $release_arrays['8.2.5'], + ], + ], + [ + '8.2.5' => $release_objects['8.2.5'], + ], + ], + '1 release, other security' => [ + [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '8.2.2', + 'recommended' => '8.2.5', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + 'security updates' => [ + // Set out of order security releases to ensure results are sorted. + $release_arrays['8.2.3-alpha'], + $release_arrays['8.2.3'], + $release_arrays['8.2.4'], + ], + ], + [ + '8.2.5' => $release_objects['8.2.5'], + '8.2.4' => $release_objects['8.2.4'], + '8.2.3' => $release_objects['8.2.3'], + '8.2.3-alpha' => $release_objects['8.2.3-alpha'], + ], + ], + '1 releases, other security lower than current version' => [ + [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '8.2.3', + 'recommended' => '8.2.5', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + 'security updates' => [ + // Set out of order security releases to ensure results are sorted. + $release_arrays['8.2.3-alpha'], + $release_arrays['8.2.3'], + $release_arrays['8.2.4'], + ], + ], + [ + '8.2.5' => $release_objects['8.2.5'], + '8.2.4' => $release_objects['8.2.4'], + ], + ], + ]; + } + + /** + * @covers ::getInstallableReleases + * + * @param array $project_data + * The project data to return from ::getProjectInfo(). + * @param \Drupal\automatic_updates_9_3_shim\ProjectRelease[] $expected_releases + * The expected releases. + * + * @dataProvider providerGetInstallableReleases + */ + public function testGetInstallableReleases(array $project_data, array $expected_releases): void { + $project_info = $this->getMockedProjectInfo($project_data); + $this->assertEqualsCanonicalizing($expected_releases, $project_info->getInstallableReleases()); + } + + /** + * @covers ::getInstallableReleases + */ + public function testInvalidProjectData(): void { + $release_arrays = static::createTestReleases(); + $project_data = [ + 'status' => UpdateManagerInterface::NOT_CURRENT, + 'existing_version' => '1.2.3', + 'releases' => [ + '8.2.5' => $release_arrays['8.2.5'], + ], + 'security updates' => [ + $release_arrays['8.2.4'], + $release_arrays['8.2.3'], + $release_arrays['8.2.3-alpha'], + ], + ]; + $project_info = $this->getMockedProjectInfo($project_data); + $this->expectException('LogicException'); + $this->expectExceptionMessage('Drupal core is out of date, but the recommended version could not be determined.'); + $project_info->getInstallableReleases(); + } + + /** + * @covers ::getInstalledVersion + */ + public function testGetInstalledVersion(): void { + $project_info = $this->getMockedProjectInfo(['existing_version' => '1.2.3']); + $this->assertSame('1.2.3', $project_info->getInstalledVersion()); + } + + /** + * Mocks a ProjectInfo object. + * + * @param array $project_data + * The project info that should be returned by the mock's ::getProjectInfo() + * method. + * + * @return \Drupal\automatic_updates\ProjectInfo + * The mocked object. + */ + private function getMockedProjectInfo(array $project_data): ProjectInfo { + $project_info = $this->getMockBuilder(ProjectInfo::class) + ->onlyMethods(['getProjectInfo']) + ->getMock(); + $project_info->expects($this->any()) + ->method('getProjectInfo') + ->willReturn($project_data); + return $project_info; + } + +} -- GitLab