Commit 1b821903 authored by xjm's avatar xjm

Issue #2917600 by tedbow, alexpott, catch, anthonyf, xjm, andypost, Alan D.,...

Issue #2917600 by tedbow, alexpott, catch, anthonyf, xjm, andypost, Alan D., moshe weitzman, Berdir: update_fix_compatibility() puts sites into unrecoverable state
parent 4ff38482
......@@ -78,9 +78,13 @@
* Loads .install files for installed modules to initialize the update system.
*/
function drupal_load_updates() {
/** @var \Drupal\Core\Extension\ModuleExtensionList $extension_list_module */
$extension_list_module = \Drupal::service('extension.list.module');
foreach (drupal_get_installed_schema_version(NULL, FALSE, TRUE) as $module => $schema_version) {
if ($schema_version > -1) {
module_load_install($module);
if ($extension_list_module->exists($module) && !$extension_list_module->checkIncompatibility($module)) {
if ($schema_version > -1) {
module_load_install($module);
}
}
}
}
......
......@@ -14,8 +14,13 @@
/**
* Disables any extensions that are incompatible with the current core version.
*
* @deprecated in Drupal 8.8.4 and is removed from Drupal 9.0.0.
*
* @see https://www.drupal.org/node/3026100
*/
function update_fix_compatibility() {
@trigger_error(__FUNCTION__ . '() is deprecated in Drupal 8.8.4 and will be removed before Drupal 9.0.0. There is no replacement. See https://www.drupal.org/node/3026100', E_USER_DEPRECATED);
// Fix extension objects if the update is being done via Drush 8. In non-Drush
// environments this will already be fixed by the UpdateKernel this point.
UpdateKernel::fixSerializedExtensionObjects(\Drupal::getContainer());
......@@ -306,9 +311,11 @@ function update_get_update_list() {
$ret = ['system' => []];
$modules = drupal_get_installed_schema_version(NULL, FALSE, TRUE);
/** @var \Drupal\Core\Extension\ExtensionList $extension_list */
$extension_list = \Drupal::service('extension.list.module');
foreach ($modules as $module => $schema_version) {
// Skip uninstalled and incompatible modules.
if ($schema_version == SCHEMA_UNINSTALLED || update_check_incompatibility($module)) {
if ($schema_version == SCHEMA_UNINSTALLED || $extension_list->checkIncompatibility($module)) {
continue;
}
// Display a requirements error if the user somehow has a schema version
......
......@@ -563,4 +563,21 @@ protected function createExtensionInfo(Extension $extension) {
return $info;
}
/**
* Tests the compatibility of an extension.
*
* @param string $name
* The extension name to check.
*
* @return bool
* TRUE if the extension is incompatible and FALSE if not.
*
* @throws \Drupal\Core\Extension\Exception\UnknownExtensionException
* If there is no extension with the supplied name.
*/
public function checkIncompatibility($name) {
$extension = $this->get($name);
return $extension->info['core_incompatible'] || (isset($extension->info['php']) && version_compare(phpversion(), $extension->info['php']) < 0);
}
}
......@@ -144,7 +144,6 @@ public function handle($op, Request $request) {
require_once $this->root . '/core/includes/update.inc';
drupal_load_updates();
update_fix_compatibility();
if ($request->query->get('continue')) {
$_SESSION['update_ignore_warnings'] = TRUE;
......
......@@ -29,6 +29,9 @@
*/
function system_requirements($phase) {
global $install_state;
// Reset the extension lists.
\Drupal::service('extension.list.module')->reset();
\Drupal::service('extension.list.theme')->reset();
$requirements = [];
// Report Drupal version
......@@ -834,28 +837,71 @@ function system_requirements($phase) {
}
// Display an error if a newly introduced dependency in a module is not resolved.
if ($phase == 'update') {
if ($phase === 'update' || $phase === 'runtime') {
$create_extension_incompatibility_list = function ($extension_names, $description, $title) {
// Use an inline twig template to:
// - Concatenate two MarkupInterface objects and preserve safeness.
// - Use the item_list theme for the extension list.
$template = [
'#type' => 'inline_template',
'#template' => '{{ description }}{{ extensions }}',
'#context' => [
'extensions' => [
'#theme' => 'item_list',
],
],
];
$template['#context']['extensions']['#items'] = $extension_names;
$template['#context']['description'] = $description;
return [
'title' => $title,
'value' => [
'list' => $template,
'handbook_link' => [
'#markup' => t(
'Review the <a href=":url"> suggestions for resolving this incompatibility</a> to repair your installation, and then re-run update.php.',
[':url' => 'https://www.drupal.org/docs/8/update/troubleshooting-database-updates']
),
],
],
'severity' => REQUIREMENT_ERROR,
];
};
$profile = \Drupal::installProfile();
$files = \Drupal::service('extension.list.module')->getList();
foreach ($files as $module => $file) {
// Ignore disabled modules and installation profiles.
if (!$file->status || $module == $profile) {
$files += \Drupal::service('extension.list.theme')->getList();
$core_incompatible_extensions = [];
$php_incompatible_extensions = [];
foreach ($files as $extension_name => $file) {
// Ignore uninstalled extensions and installation profiles.
if (!$file->status || $extension_name == $profile) {
continue;
}
// Check the module's PHP version.
$name = $file->info['name'];
if (!empty($file->info['core_incompatible'])) {
$core_incompatible_extensions[$file->info['type']][] = $name;
}
// Check the extension's PHP version.
$php = $file->info['php'];
if (version_compare($php, PHP_VERSION, '>')) {
$requirements['php']['description'] .= t('@name requires at least PHP @version.', ['@name' => $name, '@version' => $php]);
$requirements['php']['severity'] = REQUIREMENT_ERROR;
$php_incompatible_extensions[$file->info['type']][] = $name;
}
// @todo Remove this 'if' block to allow checking requirements of themes
// https://www.drupal.org/project/drupal/issues/474684.
if ($file->info['type'] !== 'module') {
continue;
}
// Check the module's required modules.
/** @var \Drupal\Core\Extension\Dependency $requirement */
foreach ($file->requires as $requirement) {
$required_module = $requirement->getName();
// Check if the module exists.
if (!isset($files[$required_module])) {
$requirements["$module-$required_module"] = [
$requirements["$extension_name-$required_module"] = [
'title' => t('Unresolved dependency'),
'description' => t('@name requires this module.', ['@name' => $name]),
'value' => t('@required_name (Missing)', ['@required_name' => $required_module]),
......@@ -868,7 +914,7 @@ function system_requirements($phase) {
$required_name = $required_file->info['name'];
$version = str_replace(\Drupal::CORE_COMPATIBILITY . '-', '', $required_file->info['version']);
if (!$requirement->isCompatible($version)) {
$requirements["$module-$required_module"] = [
$requirements["$extension_name-$required_module"] = [
'title' => t('Unresolved dependency'),
'description' => t('@name requires this module and version. Currently using @required_name version @version', ['@name' => $name, '@required_name' => $required_name, '@version' => $version]),
'value' => t('@required_name (Version @compatibility required)', ['@required_name' => $required_name, '@compatibility' => $requirement->getConstraintString()]),
......@@ -878,6 +924,115 @@ function system_requirements($phase) {
}
}
}
if (!empty($core_incompatible_extensions['module'])) {
$requirements['module_core_incompatible'] = $create_extension_incompatibility_list(
$core_incompatible_extensions['module'],
new PluralTranslatableMarkup(
count($core_incompatible_extensions['module']),
'The following module is installed, but it is incompatible with Drupal @version:',
'The following modules are installed, but they are incompatible with Drupal @version:',
['@version' => \Drupal::VERSION]
),
new PluralTranslatableMarkup(
count($core_incompatible_extensions['module']),
'Incompatible module',
'Incompatible modules'
)
);
}
if (!empty($core_incompatible_extensions['theme'])) {
$requirements['theme_core_incompatible'] = $create_extension_incompatibility_list(
$core_incompatible_extensions['theme'],
new PluralTranslatableMarkup(
count($core_incompatible_extensions['theme']),
'The following theme is installed, but it is incompatible with Drupal @version:',
'The following themes are installed, but they are incompatible with Drupal @version:',
['@version' => \Drupal::VERSION]
),
new PluralTranslatableMarkup(
count($core_incompatible_extensions['theme']),
'Incompatible theme',
'Incompatible themes'
)
);
}
if (!empty($php_incompatible_extensions['module'])) {
$requirements['module_php_incompatible'] = $create_extension_incompatibility_list(
$php_incompatible_extensions['module'],
new PluralTranslatableMarkup(
count($php_incompatible_extensions['module']),
'The following module is installed, but it is incompatible with PHP @version:',
'The following modules are installed, but they are incompatible with PHP @version:',
['@version' => phpversion()]
),
new PluralTranslatableMarkup(
count($php_incompatible_extensions['module']),
'Incompatible module',
'Incompatible modules'
)
);
}
if (!empty($php_incompatible_extensions['theme'])) {
$requirements['theme_php_incompatible'] = $create_extension_incompatibility_list(
$php_incompatible_extensions['theme'],
new PluralTranslatableMarkup(
count($php_incompatible_extensions['theme']),
'The following theme is installed, but it is incompatible with PHP @version:',
'The following themes are installed, but they are incompatible with PHP @version:',
['@version' => phpversion()]
),
new PluralTranslatableMarkup(
count($php_incompatible_extensions['theme']),
'Incompatible theme',
'Incompatible themes'
)
);
}
// Look for invalid modules.
$extension_config = \Drupal::configFactory()->get('core.extension');
/** @var \Drupal\Core\Extension\ExtensionList $extension_list */
$extension_list = \Drupal::service('extension.list.module');
$is_missing_extension = function ($extension_name) use (&$extension_list) {
return !$extension_list->exists($extension_name);
};
$invalid_modules = array_filter(array_keys($extension_config->get('module')), $is_missing_extension);
if (!empty($invalid_modules)) {
$requirements['invalid_module'] = $create_extension_incompatibility_list(
$invalid_modules,
new PluralTranslatableMarkup(
count($invalid_modules),
'The following module is marked as installed in the core.extension configuration, but it is missing:',
'The following modules are marked as installed in the core.extension configuration, but they are missing:'
),
new PluralTranslatableMarkup(
count($invalid_modules),
'Missing or invalid module',
'Missing or invalid modules'
)
);
}
// Look for invalid themes.
$extension_list = \Drupal::service('extension.list.theme');
$invalid_themes = array_filter(array_keys($extension_config->get('theme')), $is_missing_extension);
if (!empty($invalid_themes)) {
$requirements['invalid_theme'] = $create_extension_incompatibility_list(
$invalid_themes,
new PluralTranslatableMarkup(
count($invalid_themes),
'The following theme is marked as installed in the core.extension configuration, but it is missing:',
'The following themes are marked as installed in the core.extension configuration, but they are missing:'
),
new PluralTranslatableMarkup(
count($invalid_themes),
'Missing or invalid theme',
'Missing or invalid themes'
)
);
}
}
// Returns Unicode library status and errors.
......
......@@ -2,6 +2,7 @@
namespace Drupal\Tests\system\Functional\UpdateSystem;
use Drupal\Component\Serialization\Yaml;
use Drupal\Core\Url;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\BrowserTestBase;
......@@ -16,6 +17,8 @@ class UpdateScriptTest extends BrowserTestBase {
use RequirementsPageTrait;
protected const HANDBOOK_MESSAGE = 'Review the suggestions for resolving this incompatibility to repair your installation, and then re-run update.php.';
/**
* Modules to enable.
*
......@@ -33,6 +36,13 @@ class UpdateScriptTest extends BrowserTestBase {
*/
protected $dumpHeaders = TRUE;
/**
* The URL to the status report page.
*
* @var \Drupal\Core\Url
*/
protected $statusReportUrl;
/**
* URL to the update.php script.
*
......@@ -50,6 +60,7 @@ class UpdateScriptTest extends BrowserTestBase {
protected function setUp() {
parent::setUp();
$this->updateUrl = Url::fromRoute('system.db_update');
$this->statusReportUrl = Url::fromRoute('system.status');
$this->updateUser = $this->drupalCreateUser(['administer software updates', 'access site in maintenance mode']);
}
......@@ -166,6 +177,227 @@ public function testRequirements() {
$this->assertSession()->responseContains('Update script test requires this module and version. Currently using Node version ' . \Drupal::VERSION);
}
/**
* Tests that extension compatibility changes are handled correctly.
*
* @param array $correct_info
* The initial values for info.yml fail. These should compatible with core.
* @param array $breaking_info
* The values to the info.yml that are not compatible with core.
* @param string $expected_error
* The expected error.
*
* @dataProvider providerExtensionCompatibilityChange
*/
public function testExtensionCompatibilityChange(array $correct_info, array $breaking_info, $expected_error) {
$extension_type = $correct_info['type'];
$this->drupalLogin(
$this->drupalCreateUser(
[
'administer software updates',
'administer site configuration',
$extension_type === 'module' ? 'administer modules' : 'administer themes',
]
)
);
$extension_machine_name = "changing_extension";
$extension_name = "$extension_machine_name name";
$test_error_text = "Incompatible $extension_type "
. $expected_error
. $extension_name
. static::HANDBOOK_MESSAGE;
$base_info = ['name' => $extension_name];
if ($extension_type === 'theme') {
$base_info['base theme'] = FALSE;
}
$folder_path = \Drupal::service('site.path') . "/{$extension_type}s/$extension_machine_name";
$file_path = "$folder_path/$extension_machine_name.info.yml";
mkdir($folder_path, 0777, TRUE);
file_put_contents($file_path, Yaml::encode($base_info + $correct_info));
$this->enableExtension($extension_type, $extension_machine_name, $extension_name);
$this->assertInstalledExtensionConfig($extension_type, $extension_machine_name);
// If there are no requirements warnings or errors, we expect to be able to
// go through the update process uninterrupted.
$this->assertUpdateWithNoError($test_error_text, $extension_type, $extension_machine_name);
// Change the values in the info.yml and confirm updating is not possible.
file_put_contents($file_path, Yaml::encode($base_info + $breaking_info));
$this->assertErrorOnUpdate($test_error_text, $extension_type, $extension_machine_name);
// Fix the values in the info.yml file and confirm updating is possible
// again.
file_put_contents($file_path, Yaml::encode($base_info + $correct_info));
$this->assertUpdateWithNoError($test_error_text, $extension_type, $extension_machine_name);
}
/**
* Date provider for testExtensionCompatibilityChange().
*/
public function providerExtensionCompatibilityChange() {
$incompatible_module_message = "The following module is installed, but it is incompatible with Drupal " . \Drupal::VERSION . ":";
$incompatible_theme_message = "The following theme is installed, but it is incompatible with Drupal " . \Drupal::VERSION . ":";
return [
'module: core key incompatible' => [
[
'core_version_requirement' => '^8 || ^9',
'type' => 'module',
],
[
'core' => '7.x',
'type' => 'module',
],
$incompatible_module_message,
],
'module: core_version_requirement key incompatible' => [
[
'core_version_requirement' => '^8 || ^9',
'type' => 'module',
],
[
'core_version_requirement' => '8.7.7',
'type' => 'module',
],
$incompatible_module_message,
],
'theme: core key incompatible' => [
[
'core_version_requirement' => '^8 || ^9',
'type' => 'theme',
],
[
'core' => '7.x',
'type' => 'theme',
],
$incompatible_theme_message,
],
'theme: core_version_requirement key incompatible' => [
[
'core_version_requirement' => '^8 || ^9',
'type' => 'theme',
],
[
'core_version_requirement' => '8.7.7',
'type' => 'theme',
],
$incompatible_theme_message,
],
'module: php requirement' => [
[
'core_version_requirement' => '^8 || ^9',
'type' => 'module',
'php' => 1,
],
[
'core_version_requirement' => '^8 || ^9',
'type' => 'module',
'php' => 1000000000,
],
'The following module is installed, but it is incompatible with PHP ' . phpversion() . ":",
],
'theme: php requirement' => [
[
'core_version_requirement' => '^8 || ^9',
'type' => 'theme',
'php' => 1,
],
[
'core_version_requirement' => '^8 || ^9',
'type' => 'theme',
'php' => 1000000000,
],
'The following theme is installed, but it is incompatible with PHP ' . phpversion() . ":",
],
];
}
/**
* Tests that a missing extension prevents updates.
*
* @param string $extension_type
* The extension type, either 'module' or 'theme'.
*
* @dataProvider providerMissingExtension
*/
public function testMissingExtension($extension_type) {
$this->drupalLogin(
$this->drupalCreateUser(
[
'administer software updates',
'administer site configuration',
$extension_type === 'module' ? 'administer modules' : 'administer themes',
]
)
);
$extension_machine_name = "disappearing_$extension_type";
$extension_name = 'The magically disappearing extension';
$test_error_text = "Missing or invalid $extension_type "
. "The following $extension_type is marked as installed in the core.extension configuration, but it is missing:"
. $extension_machine_name
. static::HANDBOOK_MESSAGE;
$extension_info = [
'name' => $extension_name,
'type' => $extension_type,
'core_version_requirement' => '^8 || ^9',
];
if ($extension_type === 'theme') {
$extension_info['base theme'] = FALSE;
}
$folder_path = \Drupal::service('site.path') . "/{$extension_type}s/$extension_machine_name";
$file_path = "$folder_path/$extension_machine_name.info.yml";
mkdir($folder_path, 0777, TRUE);
file_put_contents($file_path, Yaml::encode($extension_info));
$this->enableExtension($extension_type, $extension_machine_name, $extension_name);
// If there are no requirements warnings or errors, we expect to be able to
// go through the update process uninterrupted.
$this->assertUpdateWithNoError($test_error_text, $extension_type, $extension_machine_name);
// Delete the info.yml and confirm updates are prevented.
unlink($file_path);
$this->assertErrorOnUpdate($test_error_text, $extension_type, $extension_machine_name);
// Add the info.yml file back and confirm we are able to go through the
// update process uninterrupted.
file_put_contents($file_path, Yaml::encode($extension_info));
$this->assertUpdateWithNoError($test_error_text, $extension_type, $extension_machine_name);
}
/**
* Data provider for testMissingExtension().
*/
public function providerMissingExtension() {
return [
'module' => ['module'],
'theme' => ['theme'],
];
}
/**
* Enables an extension using the UI.
*
* @param string $extension_type
* The extension type.
* @param string $extension_machine_name
* The extension machine name.
* @param string $extension_name
* The extension name.
*/
protected function enableExtension($extension_type, $extension_machine_name, $extension_name) {
if ($extension_type === 'module') {
$edit = [
"modules[$extension_machine_name][enable]" => $extension_machine_name,
];
$this->drupalPostForm('admin/modules', $edit, t('Install'));
}
elseif ($extension_type === 'theme') {
$this->drupalGet('admin/appearance');
$this->click("a[title~=\"$extension_name\"]");
}
}
/**
* Tests the effect of using the update script on the theme system.
*/
......@@ -431,4 +663,69 @@ public function getSystemSchema() {
];
}
/**
* Asserts that an installed extension's config setting is correct.
*
* @param string $extension_type
* The extension type, either 'module' or 'theme'.
* @param string $extension_machine_name
* The extension machine name.
*/
protected function assertInstalledExtensionConfig($extension_type, $extension_machine_name) {
$extension_config = $this->container->get('config.factory')->getEditable('core.extension');
$this->assertSame(0, $extension_config->get("$extension_type.$extension_machine_name"));
}
/**
* Asserts a particular error is not shown on update and status report pages.
*
* @param string $unexpected_error_text
* The error text that should not be shown.
* @param string $extension_type
* The extension type, either 'module' or 'theme'.
* @param string $extension_machine_name
* The extension machine name.
*
* @throws \Behat\Mink\Exception\ResponseTextException
*/
protected function assertUpdateWithNoError($unexpected_error_text, $extension_type, $extension_machine_name) {
$assert_session = $this->assertSession();
$this->drupalGet($this->statusReportUrl);
$this->assertSession()->pageTextNotContains($unexpected_error_text);
$this->drupalGet($this->updateUrl, ['external' => TRUE]);
$this->assertSession()->pageTextNotContains($unexpected_error_text);
$this->updateRequirementsProblem();
$this->clickLink(t('Continue'));
$assert_session->pageTextContains('No pending updates.');
$this->assertInstalledExtensionConfig($extension_type, $extension_machine_name);
}
/**
* Asserts an error is shown on the update and status report pages.