Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
UpdateVersionValidator.php 7.54 KiB
<?php

namespace Drupal\automatic_updates\Validator;

use Composer\Semver\Comparator;
use Composer\Semver\Semver;
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\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 {

  use StringTranslationTrait;

  /**
   * The config factory service.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * Constructs a UpdateVersionValidation object.
   *
   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
   *   The translation service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory service.
   */
  public function __construct(TranslationInterface $translation, ConfigFactoryInterface $config_factory) {
    $this->setStringTranslation($translation);
    $this->configFactory = $config_factory;
  }

  /**
   * Returns the running core version, according to the Update module.
   *
   * @return string
   *   The running core version as known to the Update module.
   */
  protected function getCoreVersion(): string {
    return (new ProjectInfo('drupal'))->getInstalledVersion();
  }

  /**
   * Validates that core is being updated within an allowed version range.
   *
   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
   *   The event object.
   */
  public function checkUpdateVersion(PreOperationStageEvent $event): void {
    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();
      if (!$package_versions) {
        // During readiness checks we might not have a version to update to.
        // Use the next possible update version to run checks against.
        return $this->getNextPossibleUpdateVersion();
      }
    }
    else {
      // If the stage has begun its life cycle, we expect it knows the desired
      // package versions.
      $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];
    }
    return NULL;
  }

  /**
   * Gets the next possible update version, if any.
   *
   * @return string|null
   *   The next possible update version if available, otherwise NULL.
   */
  protected function getNextPossibleUpdateVersion(): ?string {
    $project_info = new ProjectInfo('drupal');
    $installed_version = $project_info->getInstalledVersion();
    if ($possible_releases = $project_info->getInstallableReleases()) {
      foreach ($possible_releases as $possible_release) {
        $possible_version = $possible_release->getVersion();
        if (Semver::satisfies($possible_release->getVersion(), "~$installed_version")) {
          return $possible_version;
        }
      }
    }
    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();
    $variables = [
      '@to_version' => $to_version_string,
      '@from_version' => $from_version_string,
    ];
    $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 (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),
      ]);
    }
    $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),
      ]);
    }
    if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
      if (!$this->configFactory->get('automatic_updates.settings')->get('allow_core_minor_updates')) {
        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),
        ]);
      }
    }
    return NULL;
  }

  /**
   * Determines if a stage is supported by this validator.
   *
   * @param \Drupal\package_manager\Stage $stage
   *   The stage to check.
   *
   * @return bool
   *   TRUE if the stage is supported by this validator, otherwise FALSE.
   */
  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;
  }

}