diff --git a/automatic_updates.install b/automatic_updates.install index 10f6b11..d7ac9f8 100644 --- a/automatic_updates.install +++ b/automatic_updates.install @@ -5,15 +5,78 @@ * Automatic updates install file. */ +use Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface; use Drupal\Core\StringTranslation\PluralTranslatableMarkup; +use Drupal\Core\Url; /** * Requirements checks for automatic updates. */ function automatic_updates_requirements() { $requirements = []; + _automatic_updates_checker_requirements($requirements); + _automatic_updates_psa_requirements($requirements); + return $requirements; +} + +/** + * Display requirements from results of readiness checker. + * + * @param array $requirements + * The requirements array. + */ +function _automatic_updates_checker_requirements(array &$requirements) { + /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */ + $checker = \Drupal::service('automatic_updates.readiness_checker'); + if (!$checker->isEnabled()) { + return; + } + $checker_results = $checker->results(); + $last_check_timestamp = $checker->timestamp(); + $requirements['automatic_updates_readiness'] = [ + 'title' => t('Update readiness checks'), + 'severity' => REQUIREMENT_OK, + 'value' => t('Your site is ready to for automatic updates.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']), + ]; + if (!empty($checker_results)) { + $requirements['automatic_updates_readiness']['severity'] = REQUIREMENT_ERROR; + $requirements['automatic_updates_readiness']['value'] = new PluralTranslatableMarkup(count($checker_results), '@count check failed:', '@count checks failed:'); + $requirements['automatic_updates_readiness']['description'] = [ + '#theme' => 'item_list', + '#items' => $checker_results, + ]; + } + if (\Drupal::time()->getRequestTime() > $last_check_timestamp + ReadinessCheckerManagerInterface::LAST_CHECKED_WARNING) { + $requirements['automatic_updates_readiness']['severity'] = REQUIREMENT_ERROR; + $requirements['automatic_updates_readiness']['value'] = t('Your site has not recently checked if it is ready to apply automatic updates.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']); + $readiness_check = Url::fromRoute('automatic_updates.update_readiness'); + $time_ago = \Drupal::service('date.formatter')->formatTimeDiffSince($last_check_timestamp); + if ($last_check_timestamp === 0) { + $requirements['automatic_updates_readiness']['description'] = t('Run readiness checks manually.', [ + '@link' => $readiness_check->toString(), + ]); + } + elseif ($readiness_check->access()) { + $requirements['automatic_updates_readiness']['description'] = t('Last run @time ago. Run readiness checks manually.', [ + '@time' => $time_ago, + '@link' => $readiness_check->toString(), + ]); + } + else { + $requirements['automatic_updates_readiness']['description'] = t('Readiness checks were last run @time ago.', ['@time' => $time_ago]); + } + } +} + +/** + * Display requirements from public service announcements. + * + * @param array $requirements + * The requirements array. + */ +function _automatic_updates_psa_requirements(array &$requirements) { if (!\Drupal::config('automatic_updates.settings')->get('enable_psa')) { - return $requirements; + return; } /** @var \Drupal\automatic_updates\Services\AutomaticUpdatesPsa $psa */ $psa = \Drupal::service('automatic_updates.psa'); @@ -31,5 +94,4 @@ function automatic_updates_requirements() { '#items' => $messages, ]; } - return $requirements; } diff --git a/automatic_updates.module b/automatic_updates.module index 34ea032..ea7c5ea 100644 --- a/automatic_updates.module +++ b/automatic_updates.module @@ -5,6 +5,8 @@ * Contains automatic_updates.module.. */ +use Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface; + /** * Implements hook_page_top(). */ @@ -34,5 +36,25 @@ function automatic_updates_page_top(array &$page_top) { foreach ($psa->getPublicServiceMessages() as $psa) { \Drupal::messenger()->addError($psa); } + $last_check_timestamp = \Drupal::service('automatic_updates.readiness_checker')->timestamp(); + if (\Drupal::time()->getRequestTime() > $last_check_timestamp + ReadinessCheckerManagerInterface::LAST_CHECKED_WARNING) { + \Drupal::messenger()->addError(t('Your site has not recently run an update readiness check.')); + } + $results = \Drupal::service('automatic_updates.readiness_checker')->results(); + if ($results) { + \Drupal::messenger()->addError(t('Your site is currently failing readiness checks for automatic updates. It cannot be automatically updated until further action is performed:', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks'])); + foreach ($results as $message) { + \Drupal::messenger()->addError($message); + } + } } } + +/** + * Implements hook_cron(). + */ +function automatic_updates_cron() { + /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */ + $checker = \Drupal::service('automatic_updates.readiness_checker'); + $checker->run(); +} diff --git a/automatic_updates.routing.yml b/automatic_updates.routing.yml index 18c0868..453aa43 100644 --- a/automatic_updates.routing.yml +++ b/automatic_updates.routing.yml @@ -7,3 +7,13 @@ automatic_updates.admin_form: _permission: 'administer software updates' options: _admin_route: TRUE + +automatic_updates.update_readiness: + path: '/admin/config/automatic_updates/readiness' + defaults: + _controller: '\Drupal\automatic_updates\Controller\ReadinessCheckerController::run' + _title: 'Update readiness checking...' + requirements: + _permission: 'administer software updates' + options: + _admin_route: TRUE diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index 26d646a..f3c0964 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -2,6 +2,10 @@ services: logger.channel.automatic_updates: parent: logger.channel_base arguments: ['automatic_updates'] + automatic_updates.drupal_finder: + class: DrupalFinder\DrupalFinder + automatic_updates.filesystem_cache: + class: League\Flysystem\Cached\Storage\Memory automatic_updates.psa: class: Drupal\automatic_updates\Services\AutomaticUpdatesPsa arguments: @@ -13,3 +17,25 @@ services: - '@extension.list.profile' - '@extension.list.theme' - '@logger.channel.automatic_updates' + automatic_updates.readiness_checker: + class: Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManager + arguments: + - '@state' + - '@config.factory' + tags: + - { name: service_collector, tag: readiness_checker, call: addChecker } + automatic_updates.readonly_checker: + class: Drupal\automatic_updates\ReadinessChecker\ReadOnlyFilesystem + arguments: + - '@logger.channel.automatic_updates' + - '@automatic_updates.drupal_finder' + - '@automatic_updates.filesystem_cache' + tags: + - { name: readiness_checker, priority: 100 } + automatic_updates.disk_space_checker: + class: Drupal\automatic_updates\ReadinessChecker\DiskSpace + arguments: + - '@logger.channel.automatic_updates' + - '@automatic_updates.drupal_finder' + tags: + - { name: readiness_checker } diff --git a/composer.json b/composer.json index 0e18c77..e366d15 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,9 @@ }, "require": { "ext-json": "*", - "composer/semver": "^1.0@dev" + "composer/semver": "^1.0", + "league/flysystem": "^1.1", + "league/flysystem-cached-adapter": "^1.0", + "webflo/drupal-finder": "^1.1" } } diff --git a/config/install/automatic_updates.settings.yml b/config/install/automatic_updates.settings.yml index 085f065..01c1bf1 100644 --- a/config/install/automatic_updates.settings.yml +++ b/config/install/automatic_updates.settings.yml @@ -3,3 +3,4 @@ # https://www.drupal.org/project/automatic_updates/issues/3045273 psa_endpoint: 'http://localhost/automatic_updates/test-json' enable_psa: true +enable_readiness_checks: true diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml index ca29403..eeeb8c8 100644 --- a/config/schema/automatic_updates.schema.yml +++ b/config/schema/automatic_updates.schema.yml @@ -8,3 +8,6 @@ automatic_updates.settings: enable_psa: type: boolean label: 'Enable PSA notices' + enable_readiness_checks: + type: boolean + label: 'Enable readiness checks' diff --git a/src/Controller/ReadinessCheckerController.php b/src/Controller/ReadinessCheckerController.php new file mode 100644 index 0000000..e91e35a --- /dev/null +++ b/src/Controller/ReadinessCheckerController.php @@ -0,0 +1,59 @@ +checker = $checker; + $this->stringTranslation = $string_translation; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('automatic_updates.readiness_checker'), + $container->get('string_translation') + ); + } + + /** + * Run the readiness checkers. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A redirect + */ + public function run() { + $messages = $this->checker->run(); + if (empty($messages)) { + $this->messenger()->addStatus($this->t('No issues found. Your site is ready to for automatic updates.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks'])); + } + return $this->redirect('automatic_updates.admin_form'); + } + +} diff --git a/src/Form/AdminForm.php b/src/Form/AdminForm.php index 78217e8..ed51f7d 100644 --- a/src/Form/AdminForm.php +++ b/src/Form/AdminForm.php @@ -4,12 +4,38 @@ namespace Drupal\automatic_updates\Form; use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Url; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Administration form for automatic updates. */ class AdminForm extends ConfigFormBase { + /** + * The readiness checker. + * + * @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface + */ + protected $checker; + + /** + * The data formatter. + * + * @var \Drupal\Core\Datetime\DateFormatterInterface + */ + protected $dateFormatter; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + $instance = parent::create($container); + $instance->checker = $container->get('automatic_updates.readiness_checker'); + $instance->dateFormatter = $container->get('date.formatter'); + return $instance; + } + /** * {@inheritdoc} */ @@ -36,6 +62,18 @@ class AdminForm extends ConfigFormBase { '#title' => $this->t('Enable messaging of public service alerts (PSAs)'), '#default_value' => $config->get('enable_psa'), ]; + $last_check_timestamp = $this->checker->timestamp(); + $form['enable_readiness_checks'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Check the readiness of automatically updating the site.'), + '#default_value' => $config->get('enable_readiness_checks'), + ]; + if ($this->checker->isEnabled()) { + $form['enable_readiness_checks']['#description'] = $this->t('Readiness checks were last run @time ago. Run the readiness checks manually.', [ + '@time' => $this->dateFormatter->formatTimeDiffSince($last_check_timestamp), + '@link' => Url::fromRoute('automatic_updates.update_readiness')->toString(), + ]); + } return parent::buildForm($form, $form_state); } diff --git a/src/ReadinessChecker/DiskSpace.php b/src/ReadinessChecker/DiskSpace.php new file mode 100644 index 0000000..4809c8b --- /dev/null +++ b/src/ReadinessChecker/DiskSpace.php @@ -0,0 +1,152 @@ +logger = $logger; + $this->drupalFinder = $drupal_finder; + } + + /** + * {@inheritdoc} + */ + public function run() { + $messages = []; + if (!$this->getDrupalRoot()) { + $messages[] = $this->t('The Drupal root directory could not be located.'); + return $messages; + } + $this->diskSpace($messages); + return $messages; + } + + /** + * Get the Drupal root path. + * + * @return string + * The Drupal root path. + */ + protected function getDrupalRoot() { + if (!$this->drupalRoot && $this->drupalFinder->locateRoot(getcwd())) { + $this->drupalRoot = $this->drupalFinder->getDrupalRoot(); + } + return $this->drupalRoot; + } + + /** + * Get the vendor path. + * + * @return string + * The vendor path. + */ + protected function getVendorPath() { + if (!$this->vendorPath && $this->drupalFinder->locateRoot(getcwd())) { + $this->vendorPath = $this->drupalFinder->getVendorDir(); + } + return $this->vendorPath; + } + + /** + * Check if the filesystem has sufficient disk space. + * + * @param array $messages + * The messages array of translatable strings. + */ + protected function diskSpace(array &$messages) { + if ($this->areSameLogicalDisk($this->getDrupalRoot(), $this->getVendorPath())) { + if (disk_free_space($this->getDrupalRoot()) < static::MINIMUM_DISK_SPACE) { + $messages[] = $this->t('Logical disk "@root" has insufficient space. There must be at least @space megabytes free.', [ + '@root' => $this->getDrupalRoot(), + '@space' => static::MINIMUM_DISK_SPACE / static::MEGABYTE_DIVISOR, + ]); + } + return; + } + if (disk_free_space($this->getDrupalRoot()) < static::MINIMUM_DISK_SPACE) { + $messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [ + '@root' => $this->getDrupalRoot(), + '@space' => static::MINIMUM_DISK_SPACE / static::MEGABYTE_DIVISOR, + ]); + } + if (disk_free_space($this->getVendorPath()) < static::MINIMUM_DISK_SPACE) { + $messages[] = $this->t('Vendor filesystem "@vendor" has insufficient space. There must be at least @space megabytes free.', [ + '@vendor' => $this->getVendorPath(), + '@space' => static::MINIMUM_DISK_SPACE / static::MEGABYTE_DIVISOR, + ]); + } + } + + /** + * Determine if the root and vendor file system are the same logical disk. + * + * @param string $root + * Root file path. + * @param string $vendor + * Vendor file path. + * + * @return bool + * TRUE if same file system, FALSE otherwise. + */ + protected function areSameLogicalDisk($root, $vendor) { + $root_statistics = stat($root); + $vendor_statistics = stat($vendor); + return $root_statistics['dev'] === $vendor_statistics['dev']; + } + +} diff --git a/src/ReadinessChecker/ReadOnlyFilesystem.php b/src/ReadinessChecker/ReadOnlyFilesystem.php new file mode 100644 index 0000000..a71c626 --- /dev/null +++ b/src/ReadinessChecker/ReadOnlyFilesystem.php @@ -0,0 +1,214 @@ +logger = $logger; + $this->drupalFinder = $drupal_finder; + $this->fileSystemCache = $filesystem_cache; + } + + /** + * {@inheritdoc} + */ + public function run() { + $messages = []; + if (!$this->getCoreFileSystem() || !$this->getCoreFileSystem()->has('core.services.yml')) { + $messages[] = $this->t('The public web directory could not be located.'); + return $messages; + } + $this->readOnly($messages); + return $messages; + } + + /** + * Get the core filesystem. + * + * @return \League\Flysystem\Filesystem|\League\Flysystem\FilesystemInterface + * The core filesystem. + */ + protected function getCoreFileSystem() { + if (!$this->coreFileSystem && $this->drupalFinder->locateRoot(getcwd())) { + $this->rootPath = $this->drupalFinder->getDrupalRoot(); + $this->coreFileSystem = new Filesystem(new CachedAdapter(new Local($this->rootPath . '/core'), $this->fileSystemCache)); + } + return $this->coreFileSystem; + } + + /** + * Get the vendor file system. + * + * @return \League\Flysystem\Filesystem|\League\Flysystem\FilesystemInterface + * The vendor filesystem. + */ + protected function getVendorFileSystem() { + if (!$this->vendorFileSystem && $this->drupalFinder->locateRoot(getcwd())) { + $this->vendorPath = $this->drupalFinder->getVendorDir(); + $this->vendorFileSystem = new Filesystem(new CachedAdapter(new Local($this->vendorPath), $this->fileSystemCache)); + } + return $this->vendorFileSystem; + } + + /** + * Check if the filesystem is read only. + * + * @param array $messages + * The messages array of translatable strings. + */ + protected function readOnly(array &$messages) { + // Disable default error handling as rename of files on read only file + // systems causes PHP errors. + restore_error_handler(); + try { + if ($this->areSameLogicalDisk($this->rootPath, $this->vendorPath)) { + $error = $this->t('Logical disk "@file_system" is read only. Updates to Drupal cannot be applied against a read only file system.', ['@file_system' => $this->rootPath]); + $this->doReadOnlyCheck($this->getCoreFileSystem(), 'core.services.yml', $messages, $error); + } + else { + $error = $this->t('Drupal core filesystem "@file_system" is read only. Updates to Drupal core cannot be applied against a read only file system.', ['@file_system' => $this->rootPath . '/core']); + $this->doReadOnlyCheck($this->getCoreFileSystem(), 'core.services.yml', $messages, $error); + $error = $this->t('Vendor filesystem "@file_system" is read only. Updates to the site\'s code base cannot be applied against a read only file system.', ['@file_system' => $this->vendorPath]); + $this->doReadOnlyCheck($this->getVendorFileSystem(), 'composer/LICENSE', $messages, $error); + } + } + catch (\LogicException $exception) { + $this->logger->error($exception->getMessage()); + $messages[] = $this->t('During read only check, a logic exception was encountered'); + } + // Re-enable the default error handling. + set_error_handler('_drupal_error_handler'); + } + + /** + * Do the read only check. + * + * @param \League\Flysystem\FilesystemInterface $filesystem + * The filesystem to test. + * @param string $file + * The file path. + * @param array $messages + * The messages array of translatable strings. + * @param \Drupal\Component\Render\MarkupInterface $message + * The error message to add if the file is read only. + */ + protected function doReadOnlyCheck(FilesystemInterface $filesystem, $file, array &$messages, MarkupInterface $message) { + try { + if ($filesystem && $filesystem->rename($file, "$file.moved")) { + // Move it back after moving it. + $filesystem->rename("$file.moved", $file); + } + else { + $this->logger->error($message); + $messages[] = $message; + } + } + catch (FileExistsException $exception) { + $this->logger->error($exception->getMessage()); + $messages[] = $this->t('File already exists at path: @file', ['@file' => $exception->getPath()]); + } + catch (FileNotFoundException $exception) { + $this->logger->error($exception->getMessage()); + $messages[] = $this->t('File not found at path: @file', ['@file' => $exception->getPath()]); + } + // Catching throwable because rename triggers error in some cases. + catch (\Throwable $throwable) { + $this->logger->error($throwable->getMessage()); + $messages[] = $throwable->getMessage(); + } + } + + /** + * Determine if the root and vendor file system are the same logical disk. + * + * @param string $root + * Root file path. + * @param string $vendor + * Vendor file path. + * + * @return bool + * TRUE if same file system, FALSE otherwise. + */ + protected function areSameLogicalDisk($root, $vendor) { + $root_statistics = stat($root); + $vendor_statistics = stat($vendor); + return $root_statistics['dev'] === $vendor_statistics['dev']; + } + +} diff --git a/src/ReadinessChecker/ReadinessCheckerInterface.php b/src/ReadinessChecker/ReadinessCheckerInterface.php new file mode 100644 index 0000000..11fe60a --- /dev/null +++ b/src/ReadinessChecker/ReadinessCheckerInterface.php @@ -0,0 +1,18 @@ +keyValue = $key_value; + $this->configFactory = $config_factory; + } + + /** + * {@inheritdoc} + */ + public function addChecker(ReadinessCheckerInterface $checker, $priority = 0) { + $this->checkers[$priority][] = $checker; + return $this; + } + + /** + * {@inheritdoc} + */ + public function run() { + $messages = []; + if (!$this->isEnabled()) { + return $messages; + } + foreach ($this->getSortedCheckers() as $checker) { + $messages = array_merge($messages, $checker->run()); + } + $this->keyValue->set('readiness_check_results', $messages); + $this->keyValue->set('readiness_check_timestamp', \Drupal::time()->getCurrentTime()); + return $messages; + } + + /** + * {@inheritdoc} + */ + public function results() { + return $this->keyValue->get('readiness_check_results', []); + } + + /** + * {@inheritdoc} + */ + public function timestamp() { + return $this->keyValue->get('readiness_check_timestamp', 0); + } + + /** + * {@inheritdoc} + */ + public function isEnabled() { + return $this->configFactory->get('automatic_updates.settings')->get('enable_readiness_checks'); + } + + /** + * Sorts checkers according to priority. + * + * @return \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerInterface[] + * A sorted array of checker objects. + */ + protected function getSortedCheckers() { + $sorted = []; + krsort($this->checkers); + + foreach ($this->checkers as $checkers) { + $sorted = array_merge($sorted, $checkers); + } + return $sorted; + } + +} diff --git a/src/ReadinessChecker/ReadinessCheckerManagerInterface.php b/src/ReadinessChecker/ReadinessCheckerManagerInterface.php new file mode 100644 index 0000000..05870f1 --- /dev/null +++ b/src/ReadinessChecker/ReadinessCheckerManagerInterface.php @@ -0,0 +1,59 @@ +container->get('logger.channel.automatic_updates'), $this->container->get('automatic_updates.drupal_finder')); + $messages = $disk_space->run(); + $this->assertEmpty($messages); + + // Out of space. + $disk_space = new TestDiskSpace($this->container->get('logger.channel.automatic_updates'), $this->container->get('automatic_updates.drupal_finder')); + $messages = $disk_space->run(); + $this->assertCount(1, $messages); + + // Out of space not the same logical disk. + $disk_space = new TestDiskSpaceNonSameDisk($this->container->get('logger.channel.automatic_updates'), $this->container->get('automatic_updates.drupal_finder')); + $messages = $disk_space->run(); + $this->assertCount(2, $messages); + } + +} + +/** + * Class TestDiskSpace. + */ +class TestDiskSpace extends DiskSpace { + + /** + * Override the default free disk space minimum to an insanely high number. + */ + const MINIMUM_DISK_SPACE = 99999999999999999999999999999999999999999999999999; + +} + +/** + * Class TestDiskSpaceNonSameDisk. + */ +class TestDiskSpaceNonSameDisk extends TestDiskSpace { + + /** + * {@inheritdoc} + */ + protected function areSameLogicalDisk($root, $vendor) { + return FALSE; + } + +} diff --git a/tests/src/Kernel/ReadinessChecker/ReadOnlyTest.php b/tests/src/Kernel/ReadinessChecker/ReadOnlyTest.php new file mode 100644 index 0000000..f95fc7b --- /dev/null +++ b/tests/src/Kernel/ReadinessChecker/ReadOnlyTest.php @@ -0,0 +1,119 @@ +createMock(Filesystem::class); + $filesystem->expects($this->any()) + ->method('has') + ->withAnyParameters() + ->will($this->onConsecutiveCalls(FALSE, TRUE, TRUE, TRUE)); + $filesystem->expects($this->any()) + ->method('rename') + ->withAnyParameters() + ->will($this->onConsecutiveCalls( + FALSE, + FALSE, + FALSE, + $this->throwException(new FileExistsException('core.services.yml')), + $this->throwException(new FileNotFoundException('composer/LICENSE')) + ) + ); + + $readonly = $this->getMockBuilder(TestReadOnlyFilesystem::class) + ->setConstructorArgs([ + $this->createMock(LoggerInterface::class), + new DrupalFinder(), + $this->createMock(CacheInterface::class), + ]) + ->setMethods([ + 'getCoreFileSystem', + 'getVendorFileSystem', + 'areSameLogicalDisk', + ]) + ->getMock(); + $readonly->method('getCoreFileSystem') + ->willReturn($filesystem); + $readonly->method('getVendorFileSystem') + ->willReturn($filesystem); + $readonly->expects($this->any()) + ->method('areSameLogicalDisk') + ->withAnyParameters() + ->will($this->onConsecutiveCalls( + TRUE, + FALSE, + FALSE, + ) + ); + + // Test can't locate drupal. + $messages = $readonly->run(); + $this->assertEquals([$this->t('The public web directory could not be located.')], $messages); + + // Test same logical disk. + $expected_messages = []; + $expected_messages[] = $this->t('Logical disk "/var/www/html" is read only. Updates to Drupal cannot be applied against a read only file system.'); + $messages = $readonly->run(); + $this->assertEquals($expected_messages, $messages); + + // Test read-only. + $expected_messages = []; + $expected_messages[] = $this->t('Drupal core filesystem "/var/www/html/core" is read only. Updates to Drupal core cannot be applied against a read only file system.'); + $expected_messages[] = $this->t('Vendor filesystem "/var/www/html/vendor" is read only. Updates to the site\'s code base cannot be applied against a read only file system.'); + $messages = $readonly->run(); + $this->assertEquals($expected_messages, $messages); + + // Test FileExistsException and FileNotFoundException. + $expected_messages = []; + $expected_messages[] = $this->t('File already exists at path: core.services.yml'); + $expected_messages[] = $this->t('File not found at path: composer/LICENSE'); + $messages = $readonly->run(); + $this->assertEquals($expected_messages, $messages); + } + +} + +/** + * Class TestReadOnlyFilesystem. + */ +class TestReadOnlyFilesystem extends ReadOnlyFilesystem { + + /** + * {@inheritdoc} + */ + protected $rootPath = '/var/www/html'; + + /** + * {@inheritdoc} + */ + protected $vendorPath = '/var/www/html/vendor'; + +} diff --git a/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php b/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php new file mode 100644 index 0000000..0e5fb95 --- /dev/null +++ b/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php @@ -0,0 +1,32 @@ +container->get('automatic_updates.readiness_checker'); + $this->assertEmpty($checker->run()); + } + +}