Skip to content
Snippets Groups Projects
Commit 6bd17e02 authored by Lucas Hedding's avatar Lucas Hedding Committed by Lucas Hedding
Browse files

Issue #3087463 by heddn, ressa: Leverage update.xml to discover core security updates

parent a4dad66e
No related branches found
No related tags found
No related merge requests found
...@@ -42,8 +42,8 @@ function _automatic_updates_checker_requirements(array &$requirements) { ...@@ -42,8 +42,8 @@ function _automatic_updates_checker_requirements(array &$requirements) {
'severity' => REQUIREMENT_OK, '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']), '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'); $error_results = $checker->getResults(ReadinessCheckerManagerInterface::ERROR);
$warning_results = $checker->getResults('warning'); $warning_results = $checker->getResults(ReadinessCheckerManagerInterface::WARNING);
$checker_results = array_merge($error_results, $warning_results); $checker_results = array_merge($error_results, $warning_results);
if (!empty($checker_results)) { if (!empty($checker_results)) {
$requirements['automatic_updates_readiness']['severity'] = $error_results ? REQUIREMENT_ERROR : REQUIREMENT_WARNING; $requirements['automatic_updates_readiness']['severity'] = $error_results ? REQUIREMENT_ERROR : REQUIREMENT_WARNING;
......
...@@ -46,7 +46,7 @@ function automatic_updates_page_top(array &$page_top) { ...@@ -46,7 +46,7 @@ function automatic_updates_page_top(array &$page_top) {
} }
/** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */ /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */
$checker = \Drupal::service('automatic_updates.readiness_checker'); $checker = \Drupal::service('automatic_updates.readiness_checker');
$results = $checker->getResults('error'); $results = $checker->getResults(ReadinessCheckerManagerInterface::ERROR);
if ($results) { 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'])); \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) { foreach ($results as $message) {
......
...@@ -103,22 +103,45 @@ class SettingsForm extends ConfigFormBase { ...@@ -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) { if (strpos(\Drupal::VERSION, '-dev') === FALSE) {
$version_array = explode('.', \Drupal::VERSION); \Drupal::service('update.manager')->refreshUpdateData();
$version_array[2]++; $available = update_get_available(TRUE);
$next_version = implode('.', $version_array); $data = update_calculate_project_data($available);
$form['experimental'] = [ // If we aren't on the recommended version for our version of Drupal, then
'#type' => 'details', // enable this experimental feature.
'#title' => t('Experimental'), if ($data['drupal']['existing_version'] !== $data['drupal']['recommended']) {
]; if (isset($data['drupal']['security updates'])) {
$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.', [ $form['experimental']['security'] = [
'@link' => Url::fromRoute('automatic_updates.inplace-update', [ '#type' => 'html_tag',
'project' => 'drupal', '#tag' => 'p',
'type' => 'core', '#value' => $this->t('A security update is available for your version of Drupal.'),
'from' => \Drupal::VERSION, '#weight' => -1,
'to' => $next_version, ];
])->toString(), }
]); $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); return parent::buildForm($form, $form_state);
......
...@@ -41,13 +41,20 @@ trait ProjectInfoTrait { ...@@ -41,13 +41,20 @@ trait ProjectInfoTrait {
protected function getInfos($extension_type) { protected function getInfos($extension_type) {
$file_paths = $this->getExtensionList($extension_type)->getPathnames(); $file_paths = $this->getExtensionList($extension_type)->getPathnames();
$infos = $this->getExtensionList($extension_type)->getAllAvailableInfo(); $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['packaged'] = $info['project'] ?? FALSE;
$info['project'] = $this->getProjectName($key, $info);
$info['install path'] = $file_paths[$key] ? dirname($file_paths[$key]) : ''; $info['install path'] = $file_paths[$key] ? dirname($file_paths[$key]) : '';
$info['project'] = $this->getProjectName($key, $info);
$info['version'] = $this->getExtensionVersion($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 { ...@@ -103,7 +110,7 @@ trait ProjectInfoTrait {
$project_name = $this->getSuffix($composer_json['name'], '/', $extension_name); $project_name = $this->getSuffix($composer_json['name'], '/', $extension_name);
} }
} }
if ($project_name === 'system') { if (substr($info['install path'], 0, 4) === "core") {
$project_name = 'drupal'; $project_name = 'drupal';
} }
return $project_name; return $project_name;
......
...@@ -87,12 +87,9 @@ class ModifiedFiles implements ReadinessCheckerInterface { ...@@ -87,12 +87,9 @@ class ModifiedFiles implements ReadinessCheckerInterface {
$messages = []; $messages = [];
$extensions = []; $extensions = [];
foreach ($this->getExtensionsTypes() as $extension_type) { foreach ($this->getExtensionsTypes() as $extension_type) {
foreach ($this->getInfos($extension_type) as $info) { $extensions[] = $this->getInfos($extension_type);
if (substr($info['install path'], 0, 4) !== 'core' || $info['project'] === 'drupal') {
$extensions[$info['project']] = $info;
}
}
} }
$extensions = array_merge(...$extensions);
$filtered_modified_files = new IgnoredPathsIteratorFilter($this->modifiedFiles->getModifiedFiles($extensions)); $filtered_modified_files = new IgnoredPathsIteratorFilter($this->modifiedFiles->getModifiedFiles($extensions));
foreach ($filtered_modified_files as $file) { 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]); $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]);
......
...@@ -107,7 +107,7 @@ class ReadinessCheckerManager implements ReadinessCheckerManagerInterface { ...@@ -107,7 +107,7 @@ class ReadinessCheckerManager implements ReadinessCheckerManagerInterface {
* {@inheritdoc} * {@inheritdoc}
*/ */
public function getCategories() { public function getCategories() {
return ['warning', 'error']; return [self::ERROR, self::WARNING];
} }
/** /**
......
...@@ -7,6 +7,16 @@ namespace Drupal\automatic_updates\ReadinessChecker; ...@@ -7,6 +7,16 @@ namespace Drupal\automatic_updates\ReadinessChecker;
*/ */
interface ReadinessCheckerManagerInterface { interface ReadinessCheckerManagerInterface {
/**
* Error category.
*/
const ERROR = 'error';
/**
* Warning category.
*/
const WARNING = 'warning';
/** /**
* Last checked ago warning (in seconds). * Last checked ago warning (in seconds).
*/ */
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
namespace Drupal\automatic_updates\Services; 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\ArchiverInterface;
use Drupal\Core\Archiver\ArchiverManager; use Drupal\Core\Archiver\ArchiverManager;
use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ConfigFactoryInterface;
...@@ -20,6 +22,7 @@ use Psr\Log\LoggerInterface; ...@@ -20,6 +22,7 @@ use Psr\Log\LoggerInterface;
* Class to apply in-place updates. * Class to apply in-place updates.
*/ */
class InPlaceUpdate implements UpdateInterface { class InPlaceUpdate implements UpdateInterface {
use ProjectInfoTrait;
/** /**
* The manifest file that lists all file deletions. * The manifest file that lists all file deletions.
...@@ -130,6 +133,12 @@ class InPlaceUpdate implements UpdateInterface { ...@@ -130,6 +133,12 @@ class InPlaceUpdate implements UpdateInterface {
* {@inheritdoc} * {@inheritdoc}
*/ */
public function update($project_name, $project_type, $from_version, $to_version) { 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; $success = FALSE;
if ($project_name === 'drupal') { if ($project_name === 'drupal') {
$project_root = $this->rootPath; $project_root = $this->rootPath;
...@@ -138,11 +147,12 @@ class InPlaceUpdate implements UpdateInterface { ...@@ -138,11 +147,12 @@ class InPlaceUpdate implements UpdateInterface {
$project_root = drupal_get_path($project_type, $project_name); $project_root = drupal_get_path($project_type, $project_name);
} }
if ($archive = $this->getArchive($project_name, $from_version, $to_version)) { 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); $success = $this->processUpdate($archive, $project_root);
} if (!$success) {
if (!$success) { $this->rollback($project_root);
$this->rollback($project_root); }
} }
} }
return $success; return $success;
...@@ -169,6 +179,53 @@ class InPlaceUpdate implements UpdateInterface { ...@@ -169,6 +179,53 @@ class InPlaceUpdate implements UpdateInterface {
return $this->archiveManager->getInstance(['filepath' => $destination]); 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. * Perform retrieval of archive, with delay if archive is still being created.
* *
......
...@@ -137,6 +137,10 @@ class ModifiedFiles implements ModifiedFilesInterface { ...@@ -137,6 +137,10 @@ class ModifiedFiles implements ModifiedFilesInterface {
*/ */
protected function getHashRequests(array $extensions) { protected function getHashRequests(array $extensions) {
foreach ($extensions as $info) { 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); $url = $this->buildUrl($info);
yield $this->getPromise($url, $info); yield $this->getPromise($url, $info);
} }
......
...@@ -178,14 +178,8 @@ class InPlaceUpdateTest extends QuickStartTestBase { ...@@ -178,14 +178,8 @@ class InPlaceUpdateTest extends QuickStartTestBase {
* Core versions data provider. * Core versions data provider.
*/ */
public function coreVersionsProvider() { public function coreVersionsProvider() {
$datum[] = [ // 8.7.2 has changes to composer.lock, therefore it currently fails update.
'from' => '8.7.0', // TODO: https://www.drupal.org/project/automatic_updates/issues/3088095
'to' => '8.7.1',
];
$datum[] = [
'from' => '8.7.1',
'to' => '8.7.2',
];
$datum[] = [ $datum[] = [
'from' => '8.7.2', 'from' => '8.7.2',
'to' => '8.7.3', 'to' => '8.7.3',
......
...@@ -65,7 +65,7 @@ class TestMissingProjectInfo extends MissingProjectInfo { ...@@ -65,7 +65,7 @@ class TestMissingProjectInfo extends MissingProjectInfo {
'package' => 'Core', 'package' => 'Core',
'version' => 'VERSION', 'version' => 'VERSION',
'packaged' => FALSE, 'packaged' => FALSE,
'project' => $this->getProjectName('system', []), 'project' => $this->getProjectName('system', ['install path' => 'core']),
'install path' => drupal_get_path('module', 'system'), 'install path' => drupal_get_path('module', 'system'),
'core' => '8.x', 'core' => '8.x',
'required' => 'true', 'required' => 'true',
......
...@@ -26,8 +26,9 @@ class ReadinessCheckerTest extends KernelTestBase { ...@@ -26,8 +26,9 @@ class ReadinessCheckerTest extends KernelTestBase {
public function testReadinessChecker() { public function testReadinessChecker() {
/** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */ /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */
$checker = $this->container->get('automatic_updates.readiness_checker'); $checker = $this->container->get('automatic_updates.readiness_checker');
$this->assertEmpty($checker->run('warning')); foreach ($checker->getCategories() as $category) {
$this->assertEmpty($checker->run('error')); $this->assertEmpty($checker->run($category));
}
} }
} }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment