diff --git a/automatic_updates.install b/automatic_updates.install index 3c54d76b6443422ea487ea55c70431b8db5c4f36..0b5505b3573adcc9c0bf8168f17a9d2b539ad764 100644 --- a/automatic_updates.install +++ b/automatic_updates.install @@ -42,8 +42,8 @@ function _automatic_updates_checker_requirements(array &$requirements) { 'severity' => REQUIREMENT_OK, 'value' => t('Your site is ready to for <a href="@readiness_checks">automatic updates</a>.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']), ]; - $error_results = $checker->getResults('error'); - $warning_results = $checker->getResults('warning'); + $error_results = $checker->getResults(ReadinessCheckerManagerInterface::ERROR); + $warning_results = $checker->getResults(ReadinessCheckerManagerInterface::WARNING); $checker_results = array_merge($error_results, $warning_results); if (!empty($checker_results)) { $requirements['automatic_updates_readiness']['severity'] = $error_results ? REQUIREMENT_ERROR : REQUIREMENT_WARNING; diff --git a/automatic_updates.module b/automatic_updates.module index 446d8e99d882bf30d993805f7cf75aa5f740d55c..94c66ca60cef70666268203c55fc3b553cd6f708 100644 --- a/automatic_updates.module +++ b/automatic_updates.module @@ -46,7 +46,7 @@ function automatic_updates_page_top(array &$page_top) { } /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */ $checker = \Drupal::service('automatic_updates.readiness_checker'); - $results = $checker->getResults('error'); + $results = $checker->getResults(ReadinessCheckerManagerInterface::ERROR); if ($results) { \Drupal::messenger()->addError(t('Your site is currently failing readiness checks for automatic updates. It cannot be <a href="@readiness_checks">automatically updated</a> until further action is performed:', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks'])); foreach ($results as $message) { diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php index 6c52fea90a4d9353bee20032b7570fa80560416a..8869cf8831506af4fb5a947b047e6a331e092a18 100644 --- a/src/Form/SettingsForm.php +++ b/src/Form/SettingsForm.php @@ -103,22 +103,45 @@ class SettingsForm extends ConfigFormBase { ], ], ]; + + $form['experimental'] = [ + '#type' => 'details', + '#title' => t('Experimental'), + ]; + $form['experimental']['update'] = [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $this->t('No update for Drupal is available for version %version.', ['%version' => \Drupal::VERSION]), + ]; if (strpos(\Drupal::VERSION, '-dev') === FALSE) { - $version_array = explode('.', \Drupal::VERSION); - $version_array[2]++; - $next_version = implode('.', $version_array); - $form['experimental'] = [ - '#type' => 'details', - '#title' => t('Experimental'), - ]; - $form['experimental']['update']['#markup'] = $this->t('Very experimental. Might break the site. No checks. Just update the files of Drupal core. <a href="@link">Update now</a>. Note: database updates are not run.', [ - '@link' => Url::fromRoute('automatic_updates.inplace-update', [ - 'project' => 'drupal', - 'type' => 'core', - 'from' => \Drupal::VERSION, - 'to' => $next_version, - ])->toString(), - ]); + \Drupal::service('update.manager')->refreshUpdateData(); + $available = update_get_available(TRUE); + $data = update_calculate_project_data($available); + // If we aren't on the recommended version for our version of Drupal, then + // enable this experimental feature. + if ($data['drupal']['existing_version'] !== $data['drupal']['recommended']) { + if (isset($data['drupal']['security updates'])) { + $form['experimental']['security'] = [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $this->t('A security update is available for your version of Drupal.'), + '#weight' => -1, + ]; + } + $form['experimental']['update'] = [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => $this->t('Even with all that caution, if you want to try it out, <a href="@link">update now</a>.', [ + '@link' => Url::fromRoute('automatic_updates.inplace-update', [ + 'project' => 'drupal', + 'type' => 'core', + 'from' => \Drupal::VERSION, + 'to' => $data['drupal']['latest_version'], + ])->toString(), + ]), + '#prefix' => 'Note: Might break the site. No readiness checks or anything in place. Just update the files of Drupal core. Database updates are not run.', + ]; + } } return parent::buildForm($form, $form_state); diff --git a/src/ProjectInfoTrait.php b/src/ProjectInfoTrait.php index b3790f6c65b36486f7b6bdf68954de2fb09460b8..9572c671da29ebb615849bbe640d677345593b54 100644 --- a/src/ProjectInfoTrait.php +++ b/src/ProjectInfoTrait.php @@ -41,13 +41,20 @@ trait ProjectInfoTrait { protected function getInfos($extension_type) { $file_paths = $this->getExtensionList($extension_type)->getPathnames(); $infos = $this->getExtensionList($extension_type)->getAllAvailableInfo(); - return array_map(function ($key, array $info) use ($file_paths) { + array_walk($infos, function (array &$info, $key) use ($file_paths) { $info['packaged'] = $info['project'] ?? FALSE; - $info['project'] = $this->getProjectName($key, $info); $info['install path'] = $file_paths[$key] ? dirname($file_paths[$key]) : ''; + $info['project'] = $this->getProjectName($key, $info); $info['version'] = $this->getExtensionVersion($info); - return $info; - }, array_keys($infos), $infos); + }); + $system = $infos['system'] ?? NULL; + $infos = array_filter($infos, function (array $info, $project_name) { + return $info && $info['project'] === $project_name; + }, ARRAY_FILTER_USE_BOTH); + if ($system) { + $infos['drupal'] = $system; + } + return $infos; } /** @@ -103,7 +110,7 @@ trait ProjectInfoTrait { $project_name = $this->getSuffix($composer_json['name'], '/', $extension_name); } } - if ($project_name === 'system') { + if (substr($info['install path'], 0, 4) === "core") { $project_name = 'drupal'; } return $project_name; diff --git a/src/ReadinessChecker/ModifiedFiles.php b/src/ReadinessChecker/ModifiedFiles.php index c96e25e230d53ef29066f2866cf506f6e7747b51..12dda13ddb30ade415aaee55ea2430df2afa6f67 100644 --- a/src/ReadinessChecker/ModifiedFiles.php +++ b/src/ReadinessChecker/ModifiedFiles.php @@ -87,12 +87,9 @@ class ModifiedFiles implements ReadinessCheckerInterface { $messages = []; $extensions = []; foreach ($this->getExtensionsTypes() as $extension_type) { - foreach ($this->getInfos($extension_type) as $info) { - if (substr($info['install path'], 0, 4) !== 'core' || $info['project'] === 'drupal') { - $extensions[$info['project']] = $info; - } - } + $extensions[] = $this->getInfos($extension_type); } + $extensions = array_merge(...$extensions); $filtered_modified_files = new IgnoredPathsIteratorFilter($this->modifiedFiles->getModifiedFiles($extensions)); foreach ($filtered_modified_files as $file) { $messages[] = $this->t('The hash for @file does not match its original. Updates that include that file will fail and require manual intervention.', ['@file' => $file]); diff --git a/src/ReadinessChecker/ReadinessCheckerManager.php b/src/ReadinessChecker/ReadinessCheckerManager.php index f41ccc5c16cbdf864911980e419542863913ca0c..443d552b83bb7e2909029647058f3b3e34706ce7 100644 --- a/src/ReadinessChecker/ReadinessCheckerManager.php +++ b/src/ReadinessChecker/ReadinessCheckerManager.php @@ -107,7 +107,7 @@ class ReadinessCheckerManager implements ReadinessCheckerManagerInterface { * {@inheritdoc} */ public function getCategories() { - return ['warning', 'error']; + return [self::ERROR, self::WARNING]; } /** diff --git a/src/ReadinessChecker/ReadinessCheckerManagerInterface.php b/src/ReadinessChecker/ReadinessCheckerManagerInterface.php index f63a40e007b052e072d44340dd0edea1bd274fa6..09523eafac7d15eae8d37f298becaa3e03394a8e 100644 --- a/src/ReadinessChecker/ReadinessCheckerManagerInterface.php +++ b/src/ReadinessChecker/ReadinessCheckerManagerInterface.php @@ -7,6 +7,16 @@ namespace Drupal\automatic_updates\ReadinessChecker; */ interface ReadinessCheckerManagerInterface { + /** + * Error category. + */ + const ERROR = 'error'; + + /** + * Warning category. + */ + const WARNING = 'warning'; + /** * Last checked ago warning (in seconds). */ diff --git a/src/Services/InPlaceUpdate.php b/src/Services/InPlaceUpdate.php index d37158f559a4cb1fce7500a45b273c1f21ab5d3c..03fd23eec31c831a5e17675277a51524d2ff5a14 100644 --- a/src/Services/InPlaceUpdate.php +++ b/src/Services/InPlaceUpdate.php @@ -2,6 +2,8 @@ namespace Drupal\automatic_updates\Services; +use Drupal\automatic_updates\ProjectInfoTrait; +use Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface; use Drupal\Core\Archiver\ArchiverInterface; use Drupal\Core\Archiver\ArchiverManager; use Drupal\Core\Config\ConfigFactoryInterface; @@ -20,6 +22,7 @@ use Psr\Log\LoggerInterface; * Class to apply in-place updates. */ class InPlaceUpdate implements UpdateInterface { + use ProjectInfoTrait; /** * The manifest file that lists all file deletions. @@ -130,6 +133,12 @@ class InPlaceUpdate implements UpdateInterface { * {@inheritdoc} */ public function update($project_name, $project_type, $from_version, $to_version) { + // Bail immediately on updates if error category checks fail. + /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $readiness_check_manager */ + $checker = \Drupal::service('automatic_updates.readiness_checker'); + if ($checker->run(ReadinessCheckerManagerInterface::ERROR)) { + return FALSE; + } $success = FALSE; if ($project_name === 'drupal') { $project_root = $this->rootPath; @@ -138,11 +147,12 @@ class InPlaceUpdate implements UpdateInterface { $project_root = drupal_get_path($project_type, $project_name); } if ($archive = $this->getArchive($project_name, $from_version, $to_version)) { - if ($this->backup($archive, $project_root)) { + $modified = $this->checkModifiedFiles($project_name, $project_type, $archive); + if (!$modified && $this->backup($archive, $project_root)) { $success = $this->processUpdate($archive, $project_root); - } - if (!$success) { - $this->rollback($project_root); + if (!$success) { + $this->rollback($project_root); + } } } return $success; @@ -169,6 +179,53 @@ class InPlaceUpdate implements UpdateInterface { return $this->archiveManager->getInstance(['filepath' => $destination]); } + /** + * Check if files are modified before applying updates. + * + * @param string $project_name + * The project name. + * @param string $project_type + * The project type. + * @param \Drupal\Core\Archiver\ArchiverInterface $archive + * The archive. + * + * @return bool + * Return TRUE if modified files exist, FALSE otherwise. + */ + protected function checkModifiedFiles($project_name, $project_type, ArchiverInterface $archive) { + $extensions = []; + foreach (['module', 'profile', 'theme'] as $extension_type) { + $extensions[] = $this->getInfos($extension_type); + } + $extensions = array_merge(...$extensions); + + /** @var \Drupal\automatic_updates\Services\ModifiedFilesInterface $modified_files */ + $modified_files = \Drupal::service('automatic_updates.modified_files'); + $files = iterator_to_array($modified_files->getModifiedFiles([$extensions[$project_name]])); + $files = array_unique($files); + $archive_files = $archive->listContents(); + foreach ($archive_files as $index => &$archive_file) { + $skipped_files = [ + self::DELETION_MANIFEST, + self::CHECKSUM_LIST, + ]; + // Skip certain files and all directories. + if (in_array($archive_file, $skipped_files, TRUE) || substr($archive_file, -1) === '/') { + unset($archive_files[$index]); + continue; + } + $this->stripFileDirectoryPath($archive_file); + } + if ($intersection = array_intersect($files, $archive_files)) { + $this->logger->error('Can not update because %count files are modified: %path', [ + '%count' => count($intersection), + '%paths' => implode(', ', $intersection), + ]); + return TRUE; + } + return FALSE; + } + /** * Perform retrieval of archive, with delay if archive is still being created. * diff --git a/src/Services/ModifiedFiles.php b/src/Services/ModifiedFiles.php index b900d53276ca30a7ef7296d1a447340ac3656fc1..98a3babdda051793f2809f0fdc815d6db07a5197 100644 --- a/src/Services/ModifiedFiles.php +++ b/src/Services/ModifiedFiles.php @@ -137,6 +137,10 @@ class ModifiedFiles implements ModifiedFilesInterface { */ protected function getHashRequests(array $extensions) { foreach ($extensions as $info) { + // We can't check for modifications if we don't know the version. + if (!($info['version'])) { + continue; + } $url = $this->buildUrl($info); yield $this->getPromise($url, $info); } diff --git a/tests/src/Build/InPlaceUpdateTest.php b/tests/src/Build/InPlaceUpdateTest.php index b9c7f0b02ba5e15b69b43e104ca3d671ea38c23a..fdd4abf9764481bb486d85297077aad4d3545e1e 100644 --- a/tests/src/Build/InPlaceUpdateTest.php +++ b/tests/src/Build/InPlaceUpdateTest.php @@ -178,14 +178,8 @@ class InPlaceUpdateTest extends QuickStartTestBase { * Core versions data provider. */ public function coreVersionsProvider() { - $datum[] = [ - 'from' => '8.7.0', - 'to' => '8.7.1', - ]; - $datum[] = [ - 'from' => '8.7.1', - 'to' => '8.7.2', - ]; + // 8.7.2 has changes to composer.lock, therefore it currently fails update. + // TODO: https://www.drupal.org/project/automatic_updates/issues/3088095 $datum[] = [ 'from' => '8.7.2', 'to' => '8.7.3', diff --git a/tests/src/Kernel/ReadinessChecker/MissingProjectInfoTest.php b/tests/src/Kernel/ReadinessChecker/MissingProjectInfoTest.php index 9929506521758f5aac1ea26e1d2d461132139edb..f01848d53d1fb9165e7384638f366196e13e254a 100644 --- a/tests/src/Kernel/ReadinessChecker/MissingProjectInfoTest.php +++ b/tests/src/Kernel/ReadinessChecker/MissingProjectInfoTest.php @@ -65,7 +65,7 @@ class TestMissingProjectInfo extends MissingProjectInfo { 'package' => 'Core', 'version' => 'VERSION', 'packaged' => FALSE, - 'project' => $this->getProjectName('system', []), + 'project' => $this->getProjectName('system', ['install path' => 'core']), 'install path' => drupal_get_path('module', 'system'), 'core' => '8.x', 'required' => 'true', diff --git a/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php b/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php index 228b73c5bfe7bb36b36a60903fa58e83c79b4fd0..0d50ef0050c7cc6006a447d61aedf4d5bb36e65e 100644 --- a/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php +++ b/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php @@ -26,8 +26,9 @@ class ReadinessCheckerTest extends KernelTestBase { public function testReadinessChecker() { /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */ $checker = $this->container->get('automatic_updates.readiness_checker'); - $this->assertEmpty($checker->run('warning')); - $this->assertEmpty($checker->run('error')); + foreach ($checker->getCategories() as $category) { + $this->assertEmpty($checker->run($category)); + } } }