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) {
'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;
......
......@@ -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) {
......
......@@ -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);
......
......@@ -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;
......
......@@ -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]);
......
......@@ -107,7 +107,7 @@ class ReadinessCheckerManager implements ReadinessCheckerManagerInterface {
* {@inheritdoc}
*/
public function getCategories() {
return ['warning', 'error'];
return [self::ERROR, self::WARNING];
}
/**
......
......@@ -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).
*/
......
......@@ -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.
*
......
......@@ -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);
}
......
......@@ -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',
......
......@@ -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',
......
......@@ -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));
}
}
}
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