diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index f135d322464ecbb8d9d87ea92e9068a310bad5e5..85bec42ebb3922cbe67c9ccd7ab03d8c14bd30d2 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -153,6 +153,13 @@ services: - '@package_manager.path_locator' tags: - { name: event_subscriber } + automatic_updates.validator.cron_server: + class: Drupal\automatic_updates\Validator\CronServerValidator + arguments: + - '@request_stack' + - '@config.factory' + tags: + - { name: event_subscriber } logger.channel.automatic_updates: parent: logger.channel_base arguments: ['automatic_updates'] diff --git a/src/Validator/CronServerValidator.php b/src/Validator/CronServerValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..5c4b9cbdccdf109d382bd86d438266616b3aabc3 --- /dev/null +++ b/src/Validator/CronServerValidator.php @@ -0,0 +1,102 @@ +<?php + +namespace Drupal\automatic_updates\Validator; + +use Drupal\automatic_updates\CronUpdater; +use Drupal\automatic_updates\Event\ReadinessCheckEvent; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Http\RequestStack; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that the current server configuration can run cron updates. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class CronServerValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * The current request. + * + * @var \Symfony\Component\HttpFoundation\Request + */ + protected $request; + + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * The type of interface between the web server and the PHP runtime. + * + * @var string + * + * @see php_sapi_name() + * @see https://www.php.net/manual/en/reserved.constants.php + */ + protected static $serverApi = PHP_SAPI; + + /** + * Constructs a CronServerValidator object. + * + * @param \Drupal\Core\Http\RequestStack $request_stack + * The request stack service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. + */ + public function __construct(RequestStack $request_stack, ConfigFactoryInterface $config_factory) { + $this->request = $request_stack->getCurrentRequest(); + $this->configFactory = $config_factory; + } + + /** + * Checks that the server is configured correctly to run cron updates. + * + * @param \Drupal\package_manager\Event\PreOperationStageEvent $event + * The event object. + */ + public function checkServer(PreOperationStageEvent $event): void { + if (!$event->getStage() instanceof CronUpdater) { + return; + } + + $current_port = (int) $this->request->getPort(); + + $alternate_port = $this->configFactory->get('automatic_updates.settings') + ->get('cron_port'); + // If no alternate port is configured, it's the same as the current port. + $alternate_port = intval($alternate_port) ?: $current_port; + + if (static::$serverApi === 'cli-server' && $current_port === $alternate_port) { + // @todo Explain how to fix this problem on our help page, and link to it, + // in https://drupal.org/i/3312669. + $event->addError([ + $this->t('Your site appears to be running on the built-in PHP web server on port @port. Drupal cannot be automatically updated with this configuration unless the site can also be reached on an alternate port.', [ + '@port' => $current_port, + ]), + ]); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + ReadinessCheckEvent::class => 'checkServer', + PreCreateEvent::class => 'checkServer', + ]; + } + +} diff --git a/tests/src/Build/CoreUpdateTest.php b/tests/src/Build/CoreUpdateTest.php index 98c837130aa83ef42096191774b1c5ed496d3f11..6e64aca6183ed1b82ba299556fc30968c2da03c1 100644 --- a/tests/src/Build/CoreUpdateTest.php +++ b/tests/src/Build/CoreUpdateTest.php @@ -133,7 +133,9 @@ class CoreUpdateTest extends UpdateTestBase { $this->createTestProject($template); $this->visit('/admin/reports/status'); - $this->getMink()->getSession()->getPage()->clickLink('Run cron'); + $mink = $this->getMink(); + $mink->assertSession()->pageTextContains('Your site is ready for automatic updates.'); + $mink->getSession()->getPage()->clickLink('Run cron'); $this->assertUpdateSuccessful('9.8.1'); } diff --git a/tests/src/Kernel/ReadinessValidation/CronServerValidatorTest.php b/tests/src/Kernel/ReadinessValidation/CronServerValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5c13b4de051517c2a3d32c40b09680825f3c28df --- /dev/null +++ b/tests/src/Kernel/ReadinessValidation/CronServerValidatorTest.php @@ -0,0 +1,121 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation; + +use Drupal\automatic_updates\CronUpdater; +use Drupal\automatic_updates\Validator\CronServerValidator; +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\package_manager\Exception\StageValidationException; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase; +use Psr\Log\Test\TestLogger; + +/** + * @covers \Drupal\automatic_updates\Validator\CronServerValidator + * + * @group automatic_updates + */ +class CronServerValidatorTest extends AutomaticUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['automatic_updates']; + + /** + * Data provider for ::testCronServerValidation(). + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerCronServerValidation(): array { + $error = ValidationResult::createError([ + 'Your site appears to be running on the built-in PHP web server on port 80. Drupal cannot be automatically updated with this configuration unless the site can also be reached on an alternate port.', + ]); + + return [ + 'PHP server with alternate port' => [ + TRUE, + 'cli-server', + [CronUpdater::DISABLED, CronUpdater::SECURITY, CronUpdater::ALL], + [], + ], + 'PHP server with same port, cron enabled' => [ + FALSE, + 'cli-server', + [CronUpdater::SECURITY, CronUpdater::ALL], + [$error], + ], + 'PHP server with same port, cron disabled' => [ + FALSE, + 'cli-server', + [CronUpdater::DISABLED], + [], + ], + 'other server with alternate port' => [ + TRUE, + 'nginx', + [CronUpdater::DISABLED, CronUpdater::SECURITY, CronUpdater::ALL], + [], + ], + 'other server with same port' => [ + FALSE, + 'nginx', + [CronUpdater::DISABLED, CronUpdater::SECURITY, CronUpdater::ALL], + [], + ], + ]; + } + + /** + * Tests server configuration validation for unattended updates. + * + * @param bool $alternate_port + * Whether or not an alternate port should be set. + * @param string $server_api + * The value of the PHP_SAPI constant, as known to the validator. + * @param string[] $cron_modes + * The cron modes to test with. Can contain any of + * \Drupal\automatic_updates\CronUpdater::DISABLED, + * \Drupal\automatic_updates\CronUpdater::SECURITY, and + * \Drupal\automatic_updates\CronUpdater::ALL. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerCronServerValidation + */ + public function testCronServerValidation(bool $alternate_port, string $server_api, array $cron_modes, array $expected_results): void { + $request = $this->container->get('request_stack')->getCurrentRequest(); + $this->assertNotEmpty($request); + $this->assertSame(80, $request->getPort()); + + $property = new \ReflectionProperty(CronServerValidator::class, 'serverApi'); + $property->setAccessible(TRUE); + $property->setValue(NULL, $server_api); + + foreach ($cron_modes as $mode) { + $this->config('automatic_updates.settings') + ->set('cron', $mode) + ->set('cron_port', $alternate_port ? 2501 : 0) + ->save(); + + $this->assertCheckerResultsFromManager($expected_results, TRUE); + + $logger = new TestLogger(); + $this->container->get('logger.factory') + ->get('automatic_updates') + ->addLogger($logger); + + // If errors were expected, cron should not have run. + $this->container->get('cron')->run(); + if ($expected_results) { + $error = new StageValidationException($expected_results); + $this->assertTrue($logger->hasRecord($error->getMessage(), RfcLogLevel::ERROR)); + } + else { + $this->assertFalse($logger->hasRecords(RfcLogLevel::ERROR)); + } + } + } + +}