Commit 8f5c3245 authored by xjm's avatar xjm

Issue #2416109 by alexpott, bircher, xjm, dawehner: Validate configuration...

Issue #2416109 by alexpott, bircher, xjm, dawehner: Validate configuration dependencies before importing configuration
parent dd0712a4
......@@ -979,6 +979,7 @@ services:
class: Drupal\Core\EventSubscriber\ConfigImportSubscriber
tags:
- { name: event_subscriber }
arguments: ['@theme_handler']
config_snapshot_subscriber:
class: Drupal\Core\EventSubscriber\ConfigSnapshotSubscriber
tags:
......
......@@ -240,11 +240,12 @@ public function getStorageComparer() {
*/
public function reset() {
$this->storageComparer->reset();
// Empty all the lists.
foreach ($this->storageComparer->getAllCollectionNames() as $collection) {
$this->processedConfiguration[$collection] = $this->storageComparer->getEmptyChangelist();
}
$this->processedExtensions = $this->getEmptyExtensionsProcessedList();
$this->createExtensionChangelist();
$this->extensionChangelist = $this->processedExtensions = $this->getEmptyExtensionsProcessedList();
$this->validated = FALSE;
$this->processedSystemTheme = FALSE;
return $this;
......@@ -359,6 +360,9 @@ protected function setProcessedExtension($type, $op, $name) {
* Populates the extension change list.
*/
protected function createExtensionChangelist() {
// Create an empty changelist.
$this->extensionChangelist = $this->getEmptyExtensionsProcessedList();
// Read the extensions information to determine changes.
$current_extensions = $this->storageComparer->getTargetStorage()->read('core.extension');
$new_extensions = $this->storageComparer->getSourceStorage()->read('core.extension');
......@@ -396,7 +400,7 @@ protected function createExtensionChangelist() {
// 0 1 actions
// @todo Move this sorting functionality to the extension system.
array_multisort(array_values($module_list), SORT_ASC, array_keys($module_list), SORT_DESC, $module_list);
$uninstall = array_intersect(array_keys($module_list), $uninstall);
$this->extensionChangelist['module']['uninstall'] = array_intersect(array_keys($module_list), $uninstall);
// Determine which modules to install.
$install = array_keys(array_diff_key($new_extensions['module'], $current_extensions['module']));
......@@ -404,22 +408,11 @@ protected function createExtensionChangelist() {
// (with dependencies installed first, and modules of the same weight sorted
// in alphabetical order).
$module_list = array_reverse($module_list);
$install = array_intersect(array_keys($module_list), $install);
$this->extensionChangelist['module']['install'] = array_intersect(array_keys($module_list), $install);
// Work out what themes to install and to uninstall.
$theme_install = array_keys(array_diff_key($new_extensions['theme'], $current_extensions['theme']));
$theme_uninstall = array_keys(array_diff_key($current_extensions['theme'], $new_extensions['theme']));
$this->extensionChangelist = array(
'module' => array(
'uninstall' => $uninstall,
'install' => $install,
),
'theme' => array(
'install' => $theme_install,
'uninstall' => $theme_uninstall,
),
);
$this->extensionChangelist['theme']['install'] = array_keys(array_diff_key($new_extensions['theme'], $current_extensions['theme']));
$this->extensionChangelist['theme']['uninstall'] = array_keys(array_diff_key($current_extensions['theme'], $new_extensions['theme']));
}
/**
......@@ -434,7 +427,7 @@ protected function createExtensionChangelist() {
* @return array
* An array of extension names.
*/
protected function getExtensionChangelist($type, $op = NULL) {
public function getExtensionChangelist($type, $op = NULL) {
if ($op) {
return $this->extensionChangelist[$type][$op];
}
......
......@@ -8,15 +8,48 @@
namespace Drupal\Core\EventSubscriber;
use Drupal\Core\Config\Config;
use Drupal\Core\Config\ConfigImporter;
use Drupal\Core\Config\ConfigImporterEvent;
use Drupal\Core\Config\ConfigImportValidateEventSubscriberBase;
use Drupal\Core\Config\ConfigNameException;
use Drupal\Core\Extension\ThemeHandlerInterface;
/**
* Config import subscriber for config import events.
*/
class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase {
/**
* Theme data.
*
* @var \Drupal\Core\Extension\Extension[]
*/
protected $themeData;
/**
* Module data.
*
* @var \Drupal\Core\Extension\Extension[]
*/
protected $moduleData;
/**
* The theme handler.
*
* @var \Drupal\Core\Extension\ThemeHandlerInterface
*/
protected $themeHandler;
/**
* Constructs the ConfigImportSubscriber.
*
* @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler
* The theme handler.
*/
public function __construct(ThemeHandlerInterface $theme_handler) {
$this->themeHandler = $theme_handler;
}
/**
* Validates the configuration to be imported.
*
......@@ -37,6 +70,253 @@ public function onConfigImporterValidate(ConfigImporterEvent $event) {
}
}
}
$config_importer = $event->getConfigImporter();
if ($config_importer->getStorageComparer()->getSourceStorage()->exists('core.extension')) {
$this->validateModules($config_importer);
$this->validateThemes($config_importer);
$this->validateDependencies($config_importer);
}
else {
$config_importer->logError($this->t('The core.extension configuration does not exist.'));
}
}
/**
* Validates module installations and uninstallations.
*
* @param \Drupal\Core\Config\ConfigImporter $config_importer
* The configuration importer.
*/
protected function validateModules(ConfigImporter $config_importer) {
$core_extension = $config_importer->getStorageComparer()->getSourceStorage()->read('core.extension');
// Get a list of modules with dependency weights as values.
$module_data = $this->getModuleData();
$nonexistent_modules = array_keys(array_diff_key($core_extension['module'], $module_data));
foreach ($nonexistent_modules as $module) {
$config_importer->logError($this->t('Unable to install the %module module since it does not exist.', array('%module' => $module)));
}
// Ensure that all modules being installed have their dependencies met.
$installs = $config_importer->getExtensionChangelist('module', 'install');
foreach ($installs as $module) {
$missing_dependencies = [];
foreach (array_keys($module_data[$module]->requires) as $required_module) {
if (!isset($core_extension['module'][$required_module])) {
$missing_dependencies[] = $module_data[$required_module]->info['name'];
}
}
if (!empty($missing_dependencies)) {
$module_name = $module_data[$module]->info['name'];
$message = $this->formatPlural(count($missing_dependencies),
'Unable to install the %module module since it requires the %required_module module.',
'Unable to install the %module module since it requires the %required_module modules.',
array('%module' => $module_name, '%required_module' => implode(', ', $missing_dependencies))
);
$config_importer->logError($message);
}
}
// Ensure that all modules being uninstalled are not required by modules
// that will be installed after the import.
$uninstalls = $config_importer->getExtensionChangelist('module', 'uninstall');
foreach ($uninstalls as $module) {
foreach (array_keys($module_data[$module]->required_by) as $dependent_module) {
if ($module_data[$dependent_module]->status && !in_array($dependent_module, $uninstalls, TRUE)) {
$module_name = $module_data[$module]->info['name'];
$dependent_module_name = $module_data[$dependent_module]->info['name'];
$config_importer->logError($this->t('Unable to uninstall the %module module since the %dependent_module module is installed.', array('%module' => $module_name, '%dependent_module' => $dependent_module_name)));
}
}
}
}
/**
* Validates theme installations and uninstallations.
*
* @param \Drupal\Core\Config\ConfigImporter $config_importer
* The configuration importer.
*/
protected function validateThemes(ConfigImporter $config_importer) {
$core_extension = $config_importer->getStorageComparer()->getSourceStorage()->read('core.extension');
// Get all themes including those that are not installed.
$theme_data = $this->getThemeData();
$installs = $config_importer->getExtensionChangelist('theme', 'install');
foreach ($installs as $key => $theme) {
if (!isset($theme_data[$theme])) {
$config_importer->logError($this->t('Unable to install the %theme theme since it does not exist.', array('%theme' => $theme)));
// Remove non-existing installs from the list so we can validate theme
// dependencies later.
unset($installs[$key]);
}
}
// Ensure that all themes being installed have their dependencies met.
foreach ($installs as $theme) {
foreach (array_keys($theme_data[$theme]->requires) as $required_theme) {
if (!isset($core_extension['theme'][$required_theme])) {
$theme_name = $theme_data[$theme]->info['name'];
$required_theme_name = $theme_data[$required_theme]->info['name'];
$config_importer->logError($this->t('Unable to install the %theme theme since it requires the %required_theme theme.', array('%theme' => $theme_name, '%required_theme' => $required_theme_name)));
}
}
}
// Ensure that all themes being uninstalled are not required by themes that
// will be installed after the import.
$uninstalls = $config_importer->getExtensionChangelist('theme', 'uninstall');
foreach ($uninstalls as $theme) {
foreach (array_keys($theme_data[$theme]->required_by) as $dependent_theme) {
if ($theme_data[$dependent_theme]->status && !in_array($dependent_theme, $uninstalls, TRUE)) {
$theme_name = $theme_data[$theme]->info['name'];
$dependent_theme_name = $theme_data[$dependent_theme]->info['name'];
$config_importer->logError($this->t('Unable to uninstall the %theme theme since the %dependent_theme theme is installed.', array('%theme' => $theme_name, '%dependent_theme' => $dependent_theme_name)));
}
}
}
}
/**
* Validates configuration being imported does not have unmet dependencies.
*
* @param \Drupal\Core\Config\ConfigImporter $config_importer
* The configuration importer.
*/
protected function validateDependencies(ConfigImporter $config_importer) {
$core_extension = $config_importer->getStorageComparer()->getSourceStorage()->read('core.extension');
$existing_dependencies = [
'config' => $config_importer->getStorageComparer()->getSourceStorage()->listAll(),
'module' => array_keys($core_extension['module']),
'theme' => array_keys($core_extension['theme']),
];
$theme_data = $this->getThemeData();
$module_data = $this->getModuleData();
// Validate the dependencies of all the configuration. We have to validate
// the entire tree because existing configuration might depend on
// configuration that is being deleted.
foreach ($config_importer->getStorageComparer()->getSourceStorage()->listAll() as $name) {
// Ensure that the config owner is installed. This checks all
// configuration including configuration entities.
list($owner,) = explode('.', $name, 2);
if ($owner !== 'core') {
$message = FALSE;
if (!isset($core_extension['module'][$owner]) && isset($module_data[$owner])) {
$message = $this->t('Configuration %name depends on the %owner module that will not be installed after import.', array(
'%name' => $name,
'%owner' => $module_data[$owner]->info['name']
));
}
elseif (!isset($core_extension['theme'][$owner]) && isset($theme_data[$owner])) {
$message = $this->t('Configuration %name depends on the %owner theme that will not be installed after import.', array(
'%name' => $name,
'%owner' => $theme_data[$owner]->info['name']
));
}
elseif (!isset($core_extension['module'][$owner]) && !isset($core_extension['theme'][$owner])) {
$message = $this->t('Configuration %name depends on the %owner extension that will not be installed after import.', array(
'%name' => $name,
'%owner' => $owner
));
}
if ($message) {
$config_importer->logError($message);
continue;
}
}
$data = $config_importer->getStorageComparer()->getSourceStorage()->read($name);
// Configuration entities have dependencies on modules, themes, and other
// configuration entities that we can validate. Their content dependencies
// are not validated since we assume that they are soft dependencies.
// Configuration entities can be identified by having 'dependencies' and
// 'uuid' keys.
if (isset($data['dependencies']) && isset($data['uuid'])) {
$dependencies_to_check = array_intersect_key($data['dependencies'], array_flip(['module', 'theme', 'config']));
foreach ($dependencies_to_check as $type => $dependencies) {
$diffs = array_diff($dependencies, $existing_dependencies[$type]);
if (!empty($diffs)) {
$message = FALSE;
switch ($type) {
case 'module':
$message = $this->formatPlural(
count($diffs),
'Configuration %name depends on the %module module that will not be installed after import.',
'Configuration %name depends on modules (%module) that will not be installed after import.',
array('%name' => $name, '%module' => implode(', ', $this->getNames($diffs, $module_data)))
);
break;
case 'theme':
$message = $this->formatPlural(
count($diffs),
'Configuration %name depends on the %theme theme that will not be installed after import.',
'Configuration %name depends on themes (%theme) that will not be installed after import.',
array('%name' => $name, '%theme' => implode(', ', $this->getNames($diffs, $theme_data)))
);
break;
case 'config':
$message = $this->formatPlural(
count($diffs),
'Configuration %name depends on the %config configuration that will not exist after import.',
'Configuration %name depends on configuration (%config) that will not exist after import.',
array('%name' => $name, '%config' => implode(', ', $diffs))
);
break;
}
if ($message) {
$config_importer->logError($message);
}
}
}
}
}
}
/**
* Gets theme data.
*
* @return \Drupal\Core\Extension\Extension[]
*/
protected function getThemeData() {
if (!isset($this->themeData)) {
$this->themeData = $this->themeHandler->rebuildThemeData();
}
return $this->themeData;
}
/**
* Gets module data.
*
* @return \Drupal\Core\Extension\Extension[]
*/
protected function getModuleData() {
if (!isset($this->moduleData)) {
$this->moduleData = system_rebuild_module_data();
}
return $this->moduleData;
}
/**
* Gets human readable extension names.
*
* @param array $names
* A list of extension machine names.
* @param \Drupal\Core\Extension\Extension[] $extension_data
* Extension data.
*
* @return array
* A list of human-readable extension names, or machine names if
* human-readable names are not available.
*/
protected function getNames(array $names, array $extension_data) {
return array_map(function ($name) use ($extension_data) {
if (isset($extension_data[$name])) {
$name = $extension_data[$name]->info['name'];
}
return $name;
}, $names);
}
}
......@@ -340,7 +340,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
}
catch (ConfigImporterException $e) {
// There are validation errors.
drupal_set_message($this->t('The configuration synchronization failed validation.'));
drupal_set_message($this->t('The configuration cannot be imported because it failed validation for the following reasons:'), 'error');
foreach ($config_importer->getErrors() as $message) {
drupal_set_message($message, 'error');
}
......
......@@ -326,7 +326,7 @@ public function testImportValidation() {
$this->drupalPostForm(NULL, array(), t('Import all'));
// Verify that the validation messages appear.
$this->assertText('The configuration synchronization failed validation.');
$this->assertText('The configuration cannot be imported because it failed validation for the following reasons:');
$this->assertText('Config import validate error 1.');
$this->assertText('Config import validate error 2.');
......@@ -453,4 +453,39 @@ public function testEntityBundleDelete() {
$this->assertNoText(format_string('core.entity_form_display.node.@type.default', array('@type' => $node_type->id())));
}
/**
* Tests config importer cannot uninstall extensions which are depended on.
*
* @see \Drupal\Core\EventSubscriber\ConfigImportSubscriber
*/
public function testExtensionValidation() {
\Drupal::service('module_installer')->install(['node']);
\Drupal::service('theme_handler')->install(['bartik']);
$this->rebuildContainer();
$staging = $this->container->get('config.storage.staging');
$this->copyConfig($this->container->get('config.storage'), $staging);
$core = $staging->read('core.extension');
// Node depends on text.
unset($core['module']['text']);
$module_data = system_rebuild_module_data();
$this->assertTrue(isset($module_data['node']->requires['text']), 'The Node module depends on the Text module.');
// Bartik depends on classy.
unset($core['theme']['classy']);
$theme_data = \Drupal::service('theme_handler')->rebuildThemeData();
$this->assertTrue(isset($theme_data['bartik']->requires['classy']), 'The Bartik theme depends on the Classy theme.');
// This module does not exist.
$core['module']['does_not_exist'] = 0;
// This theme does not exist.
$core['theme']['does_not_exist'] = 0;
$staging->write('core.extension', $core);
$this->drupalPostForm('admin/config/development/configuration', array(), t('Import all'));
$this->assertText('The configuration cannot be imported because it failed validation for the following reasons:');
$this->assertText('Unable to uninstall the Text module since the Node module is installed.');
$this->assertText('Unable to uninstall the Classy theme since the Bartik theme is installed.');
$this->assertText('Unable to install the does_not_exist module since it does not exist.');
$this->assertText('Unable to install the does_not_exist theme since it does not exist.');
}
}
......@@ -105,7 +105,7 @@ function testSiteUuidValidate() {
$staging->write('system.site', $config_data);
try {
$this->configImporter->reset()->import();
$this->assertFalse(FALSE, 'ConfigImporterException not thrown, invalid import was not stopped due to mis-matching site UUID.');
$this->fail('ConfigImporterException not thrown, invalid import was not stopped due to mis-matching site UUID.');
}
catch (ConfigImporterException $e) {
$this->assertEqual($e->getMessage(), 'There were errors validating the config synchronization.');
......@@ -541,4 +541,111 @@ function testIsInstallable() {
$this->assertTrue($this->container->get('config.storage')->exists($config_name));
}
/**
* Tests dependency validation during configuration import.
*
* @see \Drupal\Core\EventSubscriber\ConfigImportSubscriber
* @see \Drupal\Core\Config\ConfigImporter::createExtensionChangelist()
*/
public function testUnmetDependency() {
$storage = $this->container->get('config.storage');
$staging = $this->container->get('config.storage.staging');
// Test an unknown configuration owner.
$staging->write('unknown.config', ['test' => 'test']);
// Make a config entity have unmet dependencies.
$config_entity_data = $staging->read('config_test.dynamic.dotted.default');
$config_entity_data['dependencies'] = ['module' => ['unknown']];
$staging->write('config_test.dynamic.dotted.module', $config_entity_data);
$config_entity_data['dependencies'] = ['theme' => ['unknown']];
$staging->write('config_test.dynamic.dotted.theme', $config_entity_data);
$config_entity_data['dependencies'] = ['config' => ['unknown']];
$staging->write('config_test.dynamic.dotted.config', $config_entity_data);
// Make an active config depend on something that is missing in staging.
// The whole configuration needs to be consistent, not only the updated one.
$config_entity_data['dependencies'] = [];
$storage->write('config_test.dynamic.dotted.deleted', $config_entity_data);
$config_entity_data['dependencies'] = ['config' => ['config_test.dynamic.dotted.deleted']];
$storage->write('config_test.dynamic.dotted.existing', $config_entity_data);
$staging->write('config_test.dynamic.dotted.existing', $config_entity_data);
$extensions = $staging->read('core.extension');
// Add a module and a theme that do not exist.
$extensions['module']['unknown_module'] = 0;
$extensions['theme']['unknown_theme'] = 0;
// Add a module and a theme that depend on uninstalled extensions.
$extensions['module']['book'] = 0;
$extensions['theme']['bartik'] = 0;
$staging->write('core.extension', $extensions);
try {
$this->configImporter->reset()->import();
$this->fail('ConfigImporterException not thrown; an invalid import was not stopped due to missing dependencies.');
}
catch (ConfigImporterException $e) {
$this->assertEqual($e->getMessage(), 'There were errors validating the config synchronization.');
$error_log = $this->configImporter->getErrors();
$expected = [
'Unable to install the <em class="placeholder">unknown_module</em> module since it does not exist.',
'Unable to install the <em class="placeholder">Book</em> module since it requires the <em class="placeholder">Node, Text, Field, Filter, User, Entity Reference</em> modules.',
'Unable to install the <em class="placeholder">unknown_theme</em> theme since it does not exist.',
'Unable to install the <em class="placeholder">Bartik</em> theme since it requires the <em class="placeholder">Classy</em> theme.',
'Configuration <em class="placeholder">config_test.dynamic.dotted.config</em> depends on the <em class="placeholder">unknown</em> configuration that will not exist after import.',
'Configuration <em class="placeholder">config_test.dynamic.dotted.existing</em> depends on the <em class="placeholder">config_test.dynamic.dotted.deleted</em> configuration that will not exist after import.',
'Configuration <em class="placeholder">config_test.dynamic.dotted.module</em> depends on the <em class="placeholder">unknown</em> module that will not be installed after import.',
'Configuration <em class="placeholder">config_test.dynamic.dotted.theme</em> depends on the <em class="placeholder">unknown</em> theme that will not be installed after import.',
'Configuration <em class="placeholder">unknown.config</em> depends on the <em class="placeholder">unknown</em> extension that will not be installed after import.',
];
foreach ($expected as $expected_message) {
$this->assertTrue(in_array($expected_message, $error_log), $expected_message);
}
}
// Make a config entity have mulitple unmet dependencies.
$config_entity_data = $staging->read('config_test.dynamic.dotted.default');
$config_entity_data['dependencies'] = ['module' => ['unknown', 'dblog']];
$staging->write('config_test.dynamic.dotted.module', $config_entity_data);
$config_entity_data['dependencies'] = ['theme' => ['unknown', 'seven']];
$staging->write('config_test.dynamic.dotted.theme', $config_entity_data);
$config_entity_data['dependencies'] = ['config' => ['unknown', 'unknown2']];
$staging->write('config_test.dynamic.dotted.config', $config_entity_data);
try {
$this->configImporter->reset()->import();
$this->fail('ConfigImporterException not thrown, invalid import was not stopped due to missing dependencies.');
}
catch (ConfigImporterException $e) {
$this->assertEqual($e->getMessage(), 'There were errors validating the config synchronization.');
$error_log = $this->configImporter->getErrors();
$expected = [
'Configuration <em class="placeholder">config_test.dynamic.dotted.config</em> depends on configuration (<em class="placeholder">unknown, unknown2</em>) that will not exist after import.',
'Configuration <em class="placeholder">config_test.dynamic.dotted.module</em> depends on modules (<em class="placeholder">unknown, Database Logging</em>) that will not be installed after import.',
'Configuration <em class="placeholder">config_test.dynamic.dotted.theme</em> depends on themes (<em class="placeholder">unknown, Seven</em>) that will not be installed after import.',
];
foreach ($expected as $expected_message) {
$this->assertTrue(in_array($expected_message, $error_log), $expected_message);
}
}
}
/**
* Tests missing core.extension during configuration import.
*
* @see \Drupal\Core\EventSubscriber\ConfigImportSubscriber
*/
public function testMissingCoreExtension() {
$staging = $this->container->get('config.storage.staging');
$staging->delete('core.extension');
try {
$this->configImporter->reset()->import();
$this->fail('ConfigImporterException not thrown, invalid import was not stopped due to missing dependencies.');
}
catch (ConfigImporterException $e) {
$this->assertEqual($e->getMessage(), 'There were errors validating the config synchronization.');
$error_log = $this->configImporter->getErrors();
$this->assertEqual(['The core.extension configuration does not exist.'], $error_log);
}
}
}
......@@ -23,7 +23,7 @@ class FieldImportDeleteUninstallUiTest extends FieldTestBase {
*
* @var array
*/
public static $modules = array('entity_test', 'telephone', 'config', 'filter', 'text');
public static $modules = array('entity_test', 'telephone', 'config', 'filter', 'datetime');
protected function setUp() {
parent::setUp();
......@@ -48,14 +48,14 @@ public function testImportDeleteUninstall() {
))->save();
// Create a text field.
$text_field_storage = entity_create('field_storage_config', array(
'field_name' => 'field_text',
$date_field_storage = entity_create('field_storage_config', array(
'field_name' => 'field_date',
'entity_type' => 'entity_test',
'type' => 'text',
'type' => 'datetime',
));
$text_field_storage->save();
$date_field_storage->save();
entity_create('field_config', array(
'field_storage' => $text_field_storage,
'field_storage' => $date_field_storage,
'bundle' => 'entity_test',
))->save();
......@@ -63,14 +63,14 @@ public function testImportDeleteUninstall() {
$entity = entity_create('entity_test');
$value = '+0123456789';
$entity->field_tel = $value;
$entity->field_text = $this->randomMachineName(20);
$entity->field_date = time();
$entity->name->value = $this->randomMachineName();
$entity->save();
// Delete the text field before exporting configuration so that we can test
// that deleted fields that are provided by modules that will be uninstalled
// are also purged and that the UI message includes such fields.
$text_field_storage->delete();
$date_field_storage->delete();
// Verify entity has been created properly.
$id = $entity->id();
......@@ -95,22 +95,13 @@ public function testImportDeleteUninstall() {
// synchronization is correct.