Commit 0b4bf3af authored by webchick's avatar webchick
Browse files

Issue #2124535 by Berdir, alexpott, Désiré, xjm | yched: Prevent secondary...

Issue #2124535 by Berdir, alexpott, Désiré, xjm | yched: Prevent secondary configuration creates and deletes from breaking the ConfigImporter.
parent e84971c5
......@@ -108,7 +108,9 @@ public function processConfigurationBatch(array &$context) {
}
$operation = $this->getNextConfigurationOperation();
if (!empty($operation)) {
$this->processConfiguration($operation['op'], $operation['name']);
if ($this->checkOp($operation['op'], $operation['name'])) {
$this->processConfiguration($operation['op'], $operation['name']);
}
$context['message'] = t('Synchronizing configuration: @op @name.', array('@op' => $operation['op'], '@name' => $operation['name']));
$processed_count = count($this->processedConfiguration['create']) + count($this->processedConfiguration['delete']) + count($this->processedConfiguration['update']);
$context['finished'] = $processed_count / $this->totalConfigurationToProcess;
......
......@@ -14,6 +14,7 @@
use Drupal\Core\DependencyInjection\DependencySerialization;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Core\StringTranslation\TranslationManager;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
......@@ -119,6 +120,13 @@ class ConfigImporter extends DependencySerialization {
*/
protected $themeHandler;
/**
* The string translation service.
*
* @var \Drupal\Core\StringTranslation\TranslationManager
*/
protected $translationManager;
/**
* Flag set to import system.theme during processing theme enable and disables.
*
......@@ -126,6 +134,13 @@ class ConfigImporter extends DependencySerialization {
*/
protected $processedSystemTheme = FALSE;
/**
* List of errors that were logged during a config import.
*
* @var array
*/
protected $errors = array();
/**
* Constructs a configuration import object.
*
......@@ -144,8 +159,10 @@ class ConfigImporter extends DependencySerialization {
* The module handler
* @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler
* The theme handler
* @param \Drupal\Core\StringTranslation\TranslationManager $translation_manager
* The string translation service.
*/
public function __construct(StorageComparerInterface $storage_comparer, EventDispatcherInterface $event_dispatcher, ConfigManagerInterface $config_manager, LockBackendInterface $lock, TypedConfigManagerInterface $typed_config, ModuleHandlerInterface $module_handler, ThemeHandlerInterface $theme_handler) {
public function __construct(StorageComparerInterface $storage_comparer, EventDispatcherInterface $event_dispatcher, ConfigManagerInterface $config_manager, LockBackendInterface $lock, TypedConfigManagerInterface $typed_config, ModuleHandlerInterface $module_handler, ThemeHandlerInterface $theme_handler, TranslationManager $translation_manager) {
$this->storageComparer = $storage_comparer;
$this->eventDispatcher = $event_dispatcher;
$this->configManager = $config_manager;
......@@ -153,10 +170,31 @@ public function __construct(StorageComparerInterface $storage_comparer, EventDis
$this->typedConfigManager = $typed_config;
$this->moduleHandler = $module_handler;
$this->themeHandler = $theme_handler;
$this->translationManager = $translation_manager;
$this->processedConfiguration = $this->storageComparer->getEmptyChangelist();
$this->processedExtensions = $this->getEmptyExtensionsProcessedList();
}
/**
* Logs an error message.
*
* @param string $message
* The message to log.
*/
protected function logError($message) {
$this->errors[] = $message;
}
/**
* Returns error messages created while running the import.
*
* @return array
* List of messages.
*/
public function getErrors() {
return $this->errors;
}
/**
* Gets the configuration storage comparer.
*
......@@ -415,7 +453,9 @@ public function import() {
// to handle dependencies correctly.
foreach (array('delete', 'create', 'update') as $op) {
foreach ($this->getUnprocessedConfiguration($op) as $name) {
$this->processConfiguration($op, $name);
if ($this->checkOp($op, $name)) {
$this->processConfiguration($op, $name);
}
}
}
// Allow modules to react to a import.
......@@ -452,10 +492,23 @@ public function validate() {
* The change operation.
* @param string $name
* The name of the configuration to process.
*
* @throws \Exception
* Thrown when the import process fails, only thrown when no importer log is
* set, otherwise the exception message is logged and the configuration
* is skipped.
*/
protected function processConfiguration($op, $name) {
if (!$this->importInvokeOwner($op, $name)) {
$this->importConfig($op, $name);
try {
if (!$this->importInvokeOwner($op, $name)) {
$this->importConfig($op, $name);
}
}
catch (\Exception $e) {
$this->logError($this->t('Unexpected error during import with operation @op for @name: @message', array('@op' => $op, '@name' => $name, '@message' => $e->getMessage())));
// Error for that operation was logged, mark it as processed so that
// the import can continue.
$this->setProcessedConfiguration($op, $name);
}
}
......@@ -505,6 +558,68 @@ protected function processExtension($type, $op, $name) {
->resetSourceStorage();
}
/**
* Checks that the operation is still valid.
*
* During a configuration import secondary writes and deletes are possible.
* This method checks that the operation is still valid before processing a
* configuration change.
*
* @param string $op
* The change operation.
* @param string $name
* The name of the configuration to process.
*
* @throws \Drupal\Core\Config\ConfigImporterException
*
* @return bool
* TRUE is to continue processing, FALSE otherwise.
*/
protected function checkOp($op, $name) {
$target_exists = $this->storageComparer->getTargetStorage()->exists($name);
switch ($op) {
case 'delete':
if (!$target_exists) {
// The configuration has already been deleted. For example, a field
// is automatically deleted if all the instances are.
$this->setProcessedConfiguration($op, $name);
return FALSE;
}
break;
case 'create':
if ($target_exists) {
// If the target already exists, use the entity storage to delete it
// again, if is a simple config, delete it directly.
if ($entity_type_id = $this->configManager->getEntityTypeIdByName($name)) {
$entity_storage = $this->configManager->getEntityManager()->getStorage($entity_type_id);
$entity_type = $this->configManager->getEntityManager()->getDefinition($entity_type_id);
$entity = $entity_storage->load($entity_storage->getIDFromConfigName($name, $entity_type->getConfigPrefix()));
$entity->delete();
$this->logError($this->translationManager->translate('Deleted and replaced configuration entity "@name"', array('@name' => $name)));
}
else {
$this->storageComparer->getTargetStorage()->delete($name);
$this->logError($this->t('Deleted and replaced configuration "@name"', array('@name' => $name)));
}
return TRUE;
}
break;
case 'update':
if (!$target_exists) {
$this->logError($this->t('Update target "@name" is missing.', array('@name' => $name)));
// Mark as processed so that the synchronisation continues. Once the
// the current synchronisation is complete it will show up as a
// create.
$this->setProcessedConfiguration($op, $name);
return FALSE;
}
break;
}
return TRUE;
}
/**
* Writes a configuration change from the source to the target storage.
*
......@@ -574,7 +689,6 @@ protected function importInvokeOwner($op, $name) {
$this->setProcessedConfiguration($op, $name);
return TRUE;
}
return FALSE;
}
/**
......@@ -656,5 +770,31 @@ protected function reInjectMe() {
$this->typedConfigManager = \Drupal::service('config.typed');
$this->moduleHandler = \Drupal::moduleHandler();
$this->themeHandler = \Drupal::service('theme_handler');
$this->translationManager = \Drupal::service('string_translation');
}
/**
* Translates a string to the current language or to a given language.
*
* @param string $string
* A string containing the English string to translate.
* @param array $args
* An associative array of replacements to make after translation. Based
* on the first character of the key, the value is escaped and/or themed.
* See \Drupal\Component\Utility\String::format() for details.
* @param array $options
* An associative array of additional options, with the following elements:
* - 'langcode': The language code to translate to a language other than
* what is used to display the page.
* - 'context': The context the source string belongs to.
*
* @return string
* The translated string.
*
* @see \Drupal\Core\StringTranslation\TranslationManager::translate()
*/
protected function t($string, array $args = array(), array $options = array()) {
return $this->translationManager->translate($string, $args, $options);
}
}
......@@ -9,6 +9,7 @@
use Drupal\Component\Utility\String;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ConfigImporterException;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityMalformedException;
use Drupal\Core\Entity\EntityStorageBase;
......@@ -425,6 +426,9 @@ public function importCreate($name, Config $new_config, Config $old_config) {
public function importUpdate($name, Config $new_config, Config $old_config) {
$id = static::getIDFromConfigName($name, $this->entityType->getConfigPrefix());
$entity = $this->load($id);
if (!$entity) {
throw new ConfigImporterException(String::format('Attempt to update non-existing entity "@id".', array('@id' => $id)));
}
$entity->setSyncing(TRUE);
$entity->original = clone $entity;
......
......@@ -38,6 +38,9 @@ public function importCreate($name, Config $new_config, Config $old_config);
* A configuration object containing the new configuration data.
* @param \Drupal\Core\Config\Config $old_config
* A configuration object containing the old configuration data.
*
* @throws \Drupal\Core\Config\ConfigImporterException
* Thrown when the config entity that should be updated can not be found.
*/
public function importUpdate($name, Config $new_config, Config $old_config);
......
......@@ -75,7 +75,7 @@ class CustomBlockType extends ConfigEntityBase implements CustomBlockTypeInterfa
public function postSave(EntityStorageInterface $storage, $update = TRUE) {
parent::postSave($storage, $update);
if (!$update) {
if (!$update && !$this->isSyncing()) {
entity_invoke_bundle_hook('create', 'custom_block', $this->id());
if (!$this->isSyncing()) {
custom_block_add_body_field($this->id);
......
......@@ -250,7 +250,8 @@ public function submitForm(array &$form, array &$form_state) {
$this->lock,
$this->typedConfigManager,
$this->moduleHandler,
$this->themeHandler
$this->themeHandler,
$this->translationManager()
);
if ($config_importer->alreadyImporting()) {
drupal_set_message($this->t('Another request may be synchronizing configuration already.'));
......@@ -289,6 +290,12 @@ public static function processBatch(BatchConfigImporter $config_importer, $opera
$config_importer = $context['sandbox']['config_importer'];
$config_importer->$operation($context);
if ($errors = $config_importer->getErrors()) {
if (!isset($context['results']['errors'])) {
$context['results']['errors'] = array();
}
$context['results']['errors'] += $errors;
}
}
/**
......@@ -299,7 +306,16 @@ public static function processBatch(BatchConfigImporter $config_importer, $opera
*/
public static function finishBatch($success, $results, $operations) {
if ($success) {
drupal_set_message(\Drupal::translation()->translate('The configuration was imported successfully.'));
if (!empty($results['errors'])) {
foreach ($results['errors'] as $error) {
drupal_set_message($error, 'error');
watchdog('config_sync', $error, NULL, WATCHDOG_ERROR);
}
drupal_set_message(\Drupal::translation()->translate('The configuration was imported with errors.'), 'warning');
}
else {
drupal_set_message(\Drupal::translation()->translate('The configuration was imported successfully.'));
}
}
else {
// An error occurred.
......
......@@ -95,6 +95,9 @@ public function testInstallUninstall() {
// Import the configuration thereby re-installing all the modules.
$this->configImporter()->import();
// Check that there are no errors.
$this->assertIdentical($this->configImporter()->getErrors(), array());
// Check that all modules that were uninstalled are now reinstalled.
$this->assertModules(array_keys($modules_to_uninstall), TRUE);
foreach($modules_to_uninstall as $module => $info) {
......
......@@ -59,7 +59,8 @@ public function setUp() {
$this->container->get('lock'),
$this->container->get('config.typed'),
$this->container->get('module_handler'),
$this->container->get('theme_handler')
$this->container->get('theme_handler'),
$this->container->get('string_translation')
);
}
......
......@@ -7,6 +7,7 @@
namespace Drupal\config\Tests;
use Drupal\Component\Utility\String;
use Drupal\Core\Config\InstallStorage;
use Drupal\simpletest\WebTestBase;
......@@ -312,4 +313,55 @@ function prepareSiteNameUpdate($new_site_name) {
$config_data['name'] = $new_site_name;
$staging->write('system.site', $config_data);
}
/**
* Tests an import that results in an error.
*/
function testImportErrorLog() {
$name_primary = 'config_test.dynamic.primary';
$name_secondary = 'config_test.dynamic.secondary';
$staging = $this->container->get('config.storage.staging');
$uuid = $this->container->get('uuid');
$values_primary = array(
'id' => 'primary',
'label' => 'Primary',
'weight' => 0,
'style' => NULL,
'test_dependencies' => array(),
'status' => TRUE,
'uuid' => $uuid->generate(),
'langcode' => 'en',
'dependencies' => array(),
'protected_property' => null,
);
$staging->write($name_primary, $values_primary);
$values_secondary = array(
'id' => 'secondary',
'label' => 'Secondary Sync',
'weight' => 0,
'style' => NULL,
'test_dependencies' => array(),
'status' => TRUE,
'uuid' => $uuid->generate(),
'langcode' => 'en',
// Add a dependency on primary, to ensure that is synced first.
'dependencies' => array(
'entity' => array($name_primary),
),
'protected_property' => null,
);
$staging->write($name_secondary, $values_secondary);
// Verify that there are configuration differences to import.
$this->drupalGet('admin/config/development/configuration');
$this->assertNoText(t('There are no configuration changes.'));
// Attempt to import configuration and verify that an error message appears.
$this->drupalPostForm(NULL, array(), t('Import all'));
$this->assertText(String::format('Deleted and replaced configuration entity "@name"', array('@name' => $name_secondary)));
$this->assertText(t('The configuration was imported with errors.'));
$this->assertNoText(t('The configuration was imported successfully.'));
$this->assertText(t('There are no configuration changes.'));
}
}
......@@ -7,6 +7,7 @@
namespace Drupal\config\Tests;
use Drupal\Component\Utility\String;
use Drupal\Core\Config\ConfigImporter;
use Drupal\Core\Config\ConfigImporterException;
use Drupal\Core\Config\StorageComparer;
......@@ -64,7 +65,8 @@ function setUp() {
$this->container->get('lock'),
$this->container->get('config.typed'),
$this->container->get('module_handler'),
$this->container->get('theme_handler')
$this->container->get('theme_handler'),
$this->container->get('string_translation')
);
}
......@@ -149,6 +151,8 @@ function testDeleted() {
$this->assertTrue(isset($GLOBALS['hook_config_test']['delete']));
$this->assertFalse($this->configImporter->hasUnprocessedConfigurationChanges());
$logs = $this->configImporter->getErrors();
$this->assertEqual(count($logs), 0);
}
/**
......@@ -196,6 +200,274 @@ function testNew() {
// Verify that there is nothing more to import.
$this->assertFalse($this->configImporter->hasUnprocessedConfigurationChanges());
$logs = $this->configImporter->getErrors();
$this->assertEqual(count($logs), 0);
}
/**
* Tests that secondary writes are overwritten.
*/
function testSecondaryWritePrimaryFirst() {
$name_primary = 'config_test.dynamic.primary';
$name_secondary = 'config_test.dynamic.secondary';
$staging = $this->container->get('config.storage.staging');
$uuid = $this->container->get('uuid');
$values_primary = array(
'id' => 'primary',
'label' => 'Primary',
'weight' => 0,
'uuid' => $uuid->generate(),
);
$staging->write($name_primary, $values_primary);
$values_secondary = array(
'id' => 'secondary',
'label' => 'Secondary Sync',
'weight' => 0,
'uuid' => $uuid->generate(),
// Add a dependency on primary, to ensure that is synced first.
'dependencies' => array(
'entity' => array($name_primary),
)
);
$staging->write($name_secondary, $values_secondary);
// Import.
$this->configImporter->reset()->import();
$entity_storage = \Drupal::entityManager()->getStorage('config_test');
$primary = $entity_storage->load('primary');
$this->assertEqual($primary->id(), 'primary');
$this->assertEqual($primary->uuid(), $values_primary['uuid']);
$this->assertEqual($primary->label(), $values_primary['label']);
$secondary = $entity_storage->load('secondary');
$this->assertEqual($secondary->id(), 'secondary');
$this->assertEqual($secondary->uuid(), $values_secondary['uuid']);
$this->assertEqual($secondary->label(), $values_secondary['label']);
$logs = $this->configImporter->getErrors();
$this->assertEqual(count($logs), 1);
$this->assertEqual($logs[0], String::format('Deleted and replaced configuration entity "@name"', array('@name' => $name_secondary)));
}
/**
* Tests that secondary writes are overwritten.
*/
function testSecondaryWriteSecondaryFirst() {
$name_primary = 'config_test.dynamic.primary';
$name_secondary = 'config_test.dynamic.secondary';
$staging = $this->container->get('config.storage.staging');
$uuid = $this->container->get('uuid');
$values_primary = array(
'id' => 'primary',
'label' => 'Primary',
'weight' => 0,
'uuid' => $uuid->generate(),
// Add a dependency on secondary, so that is synced first.
'dependencies' => array(
'entity' => array($name_secondary),
)
);
$staging->write($name_primary, $values_primary);
$values_secondary = array(
'id' => 'secondary',
'label' => 'Secondary Sync',
'weight' => 0,
'uuid' => $uuid->generate(),
);
$staging->write($name_secondary, $values_secondary);
// Import.
$this->configImporter->reset()->import();
$entity_storage = \Drupal::entityManager()->getStorage('config_test');
$primary = $entity_storage->load('primary');
$this->assertEqual($primary->id(), 'primary');
$this->assertEqual($primary->uuid(), $values_primary['uuid']);
$this->assertEqual($primary->label(), $values_primary['label']);
$secondary = $entity_storage->load('secondary');
$this->assertEqual($secondary->id(), 'secondary');
$this->assertEqual($secondary->uuid(), $values_secondary['uuid']);
$this->assertEqual($secondary->label(), $values_secondary['label']);
$logs = $this->configImporter->getErrors();
$this->assertEqual(count($logs), 1);
$message = String::format('config_test entity with ID @name already exists', array('@name' => 'secondary'));
$this->assertEqual($logs[0], String::format('Unexpected error during import with operation @op for @name: @message.', array('@op' => 'create', '@name' => $name_primary, '@message' => $message)));
}
/**
* Tests that secondary updates for deleted files work as expected.
*/
function testSecondaryUpdateDeletedDeleterFirst() {
$name_deleter = 'config_test.dynamic.deleter';
$name_deletee = 'config_test.dynamic.deletee';
$name_other = 'config_test.dynamic.other';
$storage = $this->container->get('config.storage');
$staging = $this->container->get('config.storage.staging');
$uuid = $this->container->get('uuid');
$values_deleter = array(
'id' => 'deleter',
'label' => 'Deleter',
'weight' => 0,
'uuid' => $uuid->generate(),
);
$storage->write($name_deleter, $values_deleter);
$values_deleter['label'] = 'Updated Deleter';
$staging->write($name_deleter, $values_deleter);
$values_deletee = array(
'id' => 'deletee',
'label' => 'Deletee',
'weight' => 0,
'uuid' => $uuid->generate(),
// Add a dependency on deleter, to make sure that is synced first.
'dependencies' => array(
'entity' => array($name_deleter),
)
);
$storage->write($name_deletee, $values_deletee);
$values_deletee['label'] = 'Updated Deletee';
$staging->write($name_deletee, $values_deletee);
// Ensure that import will continue after the error.
$values_other = array(
'id' => 'other',
'label' => 'Other',
'weight' => 0,
'uuid' => $uuid->generate(),
// Add a dependency on deleter, to make sure that is synced first. This
// will also be synced after the deletee due to alphabetical ordering.
'dependencies' => array(
'entity' => array($name_deleter),
)
);
$storage->write($name_other, $values_other);
$values_other['label'] = 'Updated other';
$staging->write($name_other, $values_other);
// Check update changelist order.
$updates = $this->configImporter->reset()->getStorageComparer()->getChangelist('update');
$expected = array(
$name_deleter,
$name_deletee,
$name_other,
);
$this->assertIdentical($expected, $updates);
// Import.