diff --git a/core/includes/config.inc b/core/includes/config.inc index 5e07c54..643c351 100644 --- a/core/includes/config.inc +++ b/core/includes/config.inc @@ -23,17 +23,34 @@ function config_install_default_config($type, $name) { if (is_dir($config_dir)) { $source_storage = new FileStorage($config_dir); $target_storage = drupal_container()->get('config.storage'); - $null_storage = new NullStorage(); - - // Upon installation, only new config objects need to be created. - // config_sync_get_changes() would potentially perform a diff of hundreds or - // even thousands of config objects that happen to be contained in the - // active configuration. We leverage the NullStorage to avoid that needless - // computation of differences. - $config_changes = config_sync_get_changes($source_storage, $null_storage); - if (empty($config_changes)) { + + // If this module defines any ConfigEntity types, then create a manifest file + // for each of them with a listing of the objects it maintains. + if (function_exists($name . '_entity_info')) { + $info = module_invoke($name, 'entity_info'); + foreach ($info as $entity_type => $entity_info) { + // @todo is this a sane and/or trustworthy way to determine that this + // is in fact a config entity? + if (isset($entity_info['config prefix'])) { + $manifest_config = config('manifest.' . $entity_info['config prefix']); + foreach ($source_storage->listAll($entity_info['config prefix']) as $config_name) { + $manifest_data[] = $config_name; + } + $manifest_config->setData($manifest_data)->save(); + } + } + } + + $config_changes = array( + 'delete' => array(), + 'create' => array(), + 'change' => array(), + ); + $config_changes['create'] = $source_storage->listAll(); + if (empty($config_changes['create'])) { return; } + $remaining_changes = config_import_invoke_owner($config_changes, $source_storage, $target_storage); config_sync_changes($remaining_changes, $source_storage, $target_storage); } @@ -80,24 +97,41 @@ function config($name) { * storage, or FALSE if there are no differences. */ function config_sync_get_changes(StorageInterface $source_storage, StorageInterface $target_storage) { - $source_names = $source_storage->listAll(); - $target_names = $target_storage->listAll(); + // Config entities maintain 'manifest' files that list the objects they + // are currently handling. Each file is a simple indexed array of config + // object names. In order to generate a list of objects that have been + // created or deleted we need to open these files in both the source and + // target storage, generate an array of the objects, and compare them. + $source_config_data = array(); + foreach ($source_storage->listAll('manifest') as $name) { + $source_config_data = array_merge($source_config_data, $source_storage->read($name)); + } + + $target_config_data = array(); + foreach ($target_storage->listAll('manifest') as $name) { + $target_config_data = array_merge($target_config_data, $target_storage->read($name)); + } + $config_changes = array( - 'create' => array_diff($source_names, $target_names), + 'create' => array_diff($source_config_data, $target_config_data), 'change' => array(), - 'delete' => array_diff($target_names, $source_names), + 'delete' => array_diff($target_config_data, $source_config_data), ); - foreach (array_intersect($source_names, $target_names) as $name) { - $source_config_data = $source_storage->read($name); - $target_config_data = $target_storage->read($name); - if ($source_config_data !== $target_config_data) { - $config_changes['change'][] = $name; + + foreach (array_intersect($source_storage->listAll(), $target_storage->listAll()) as $name) { + // Ignore manifest files + if (substr($name, 0, 9) != 'manifest.') { + $source_config_data = $source_storage->read($name); + $target_config_data = $target_storage->read($name); + if ($source_config_data !== $target_config_data) { + $config_changes['change'][] = $name; + } } } // Do not trigger subsequent synchronization operations if there are no // changes in any category. - if (empty($config_changes['create']) && empty($config_changes['change']) && empty($config_changes['delete'])) { + if (empty($config_changes['create']) && empty($config_changes['change']) && empty($config_changes['delete'])) { return FALSE; } return $config_changes; diff --git a/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php b/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php index 07849c6..32474ed 100644 --- a/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php +++ b/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php @@ -65,6 +65,13 @@ public function __construct($entityType) { $this->entityInfo = entity_get_info($entityType); $this->hookLoadArguments = array(); $this->idKey = $this->entityInfo['entity keys']['id']; + + // Create the manifest file if it doesn't exist. + // @todo Not sure this is the best place for this to live + $config = config('manifest.' . $this->entityInfo['config prefix']); + if ($config->isNew()) { + $config->save(); + } } /** @@ -229,6 +236,14 @@ public function create(array $values) { $entity->{$this->uuidKey} = $uuid->generate(); } + // Add this entity to the manifest file + $config = config('manifest.' . $this->entityInfo['config prefix']); + $manifest = $config->get(); + if (!isset($manifest[$this->entityInfo['config prefix'] . '.' . $entity->id()])) { + $manifest[] = $this->entityInfo['config prefix'] . '.' . $entity->id(); + } + $config->setData($manifest)->save(); + return $entity; } @@ -256,6 +271,13 @@ public function delete($ids) { foreach ($entities as $id => $entity) { $this->invokeHook('delete', $entity); } + + // Remove the entity from the manifest file. + // Add this entity to the manifest file + $config = config('manifest.' . $this->entityInfo['config prefix']); + $manifest = $config->get(); + unset($manifest[$this->entityInfo['config prefix'] . '.' . $entity->id()]); + $config->setAll($manifest)->save(); } /** diff --git a/core/modules/config/config.admin.inc b/core/modules/config/config.admin.inc new file mode 100644 index 0000000..8017264 --- /dev/null +++ b/core/modules/config/config.admin.inc @@ -0,0 +1,99 @@ + t('There are no configuration changes.'), + ); + return $form; + } + + foreach ($config_changes as $config_change_type => $config_files) { + if (empty($config_files)) { + continue; + } + // @todo A table caption would be more appropriate, but does not have the + // visual importance of a heading. + $form[$config_change_type]['heading'] = array( + '#theme' => 'html_tag__h3', + '#tag' => 'h3', + ); + switch ($config_change_type) { + case 'create': + $form[$config_change_type]['heading']['#value'] = format_plural(count($config_files), '@count new', '@count new'); + break; + + case 'change': + $form[$config_change_type]['heading']['#value'] = format_plural(count($config_files), '@count changed', '@count changed'); + break; + + case 'delete': + $form[$config_change_type]['heading']['#value'] = format_plural(count($config_files), '@count removed', '@count removed'); + break; + } + $form[$config_change_type]['list'] = array( + '#theme' => 'table', + '#header' => array('Name'), + ); + foreach ($config_files as $config_file) { + $form[$config_change_type]['list']['#rows'][] = array($config_file); + } + } +} + +/** + * Form constructor for configuration import form. + * + * @see config_admin_import_form_submit() + * @see config_import() + */ +function config_admin_import_form($form, &$form_state) { + // Retrieve a list of differences between last known state and active store. + $source_storage = drupal_container()->get('config.storage.staging'); + $target_storage = drupal_container()->get('config.storage'); + + config_admin_sync_form($form, $form_state, $source_storage, $target_storage); + + $form['actions'] = array('#type' => 'actions'); + $form['actions']['submit'] = array( + '#type' => 'submit', + '#value' => t('Import all'), + ); + return $form; +} + +/** + * Form submission handler for config_admin_import_form(). + */ +function config_admin_import_form_submit($form, &$form_state) { + if (config_import()) { + drupal_set_message(t('The configuration was imported successfully.')); + } + else { + // Another request may be synchronizing configuration already. Wait for it + // to complete before returning the error, so already synchronized changes + // do not appear again. + lock_wait(__FUNCTION__); + drupal_set_message(t('The import failed due to an error. Any errors have been logged.'), 'error'); + } +} diff --git a/core/modules/config/config.info b/core/modules/config/config.info index 380f17e..efab7a1 100644 --- a/core/modules/config/config.info +++ b/core/modules/config/config.info @@ -3,3 +3,4 @@ description = Allows administrators to manage configuration changes. package = Core version = VERSION core = 8.x +configure = admin/config/development/sync diff --git a/core/modules/config/config.module b/core/modules/config/config.module index b3d9bbc..415a8e3 100644 --- a/core/modules/config/config.module +++ b/core/modules/config/config.module @@ -1 +1,60 @@ ' . t('About') . ''; + $output .= '

' . t('The Configuration manager module provides a user interface for importing and exporting configuration changes; i.e., for staging configuration data between multiple instances of this web site. For more information, see the online handbook entry for Configuration manager module', array( + '!url' => 'http://drupal.org/documentation/modules/config', + )) . '

'; + return $output; + + case 'admin/config/development/sync': + $output = ''; + $output .= '

' . t('Import configuration that is placed in @staging-filepath. All changes, deletions, renames, and additions are listed below.', array( + '@staging-filepath' => config_get_config_directory(CONFIG_STAGING_DIRECTORY), + )) . '

'; + return $output; + } +} + +/** + * Implements hook_permission(). + */ +function config_permission() { + $permissions['synchronize configuration'] = array( + 'title' => t('Synchronize configuration'), + 'restrict access' => TRUE, + ); + return $permissions; +} + +/** + * Implements hook_menu(). + */ +function config_menu() { + $items['admin/config/development/sync'] = array( + 'title' => 'Synchronize configuration', + 'description' => 'Synchronize configuration changes.', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('config_admin_import_form'), + 'access arguments' => array('synchronize configuration'), + 'file' => 'config.admin.inc', + ); + $items['admin/config/development/sync/import'] = array( + 'title' => 'Import', + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => -10, + ); + return $items; +} + diff --git a/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php new file mode 100644 index 0000000..173fe0d --- /dev/null +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php @@ -0,0 +1,121 @@ + 'Import/Export UI', + 'description' => 'Tests the user interface for importing/exporting configuration.', + 'group' => 'Configuration', + ); + } + + function setUp() { + parent::setUp(); + + $this->web_user = $this->drupalCreateUser(array('synchronize configuration')); + $this->drupalLogin($this->web_user); + } + + /** + * Tests importing configuration. + */ + function testImport() { + $name = 'config_test.new'; + $dynamic_name = 'config_test.dynamic.new'; + $storage = $this->container->get('config.storage'); + $staging = $this->container->get('config.storage.staging'); + + // Verify the configuration to create does not exist yet. + $this->assertIdentical($storage->exists($name), FALSE, $name . ' not found.'); + $this->assertIdentical($storage->exists($dynamic_name), FALSE, $dynamic_name . ' not found.'); + $this->assertIdentical($staging->exists($name), FALSE, $name . ' not found.'); + $this->assertIdentical($staging->exists($dynamic_name), FALSE, $dynamic_name . ' not found.'); + + // Verify that the import UI does not allow to import without exported + // configuration. + $this->drupalGet('admin/config/development/sync'); + $this->assertText('There is no base configuration.'); + + // Verify that the Export link yields to the export UI page, and export. + $this->clickLink('Export'); + $this->drupalPost(NULL, array(), t('Export all')); + + // Create new configuration objects. + $original_name_data = array( + 'add_me' => 'new value', + ); + $staging->write($name, $original_name_data); + $original_dynamic_data = array( + 'id' => 'new', + 'label' => 'New', + 'langcode' => 'und', + 'style' => '', + 'uuid' => '30df59bd-7b03-4cf7-bb35-d42fc49f0651', + ); + $staging->write($dynamic_name, $original_dynamic_data); + $this->assertIdentical($staging->exists($name), TRUE, $name . ' found.'); + $this->assertIdentical($staging->exists($dynamic_name), TRUE, $dynamic_name . ' found.'); + + // Verify that both appear as new. + $this->drupalGet('admin/config/development/sync'); + $this->assertText($name); + $this->assertText($dynamic_name); + + // Import and verify that both do not appear anymore. + $this->drupalPost(NULL, array(), t('Import all')); + $this->assertUrl('admin/config/development/sync'); + $this->assertNoText($name); + $this->assertNoText($dynamic_name); + + // Verify that there are no further changes to import. + $this->assertText(t('There are no configuration changes.')); + + // Verify that the export screen shows no changes either. + $this->drupalGet('admin/config/development/sync/export'); + $this->assertText(t('There are no configuration changes.')); + } + + /** + * Tests concurrent importing of configuration. + */ + function testImportLock() { + $name = 'config_test.new'; + $staging = $this->container->get('config.storage.staging'); + + // Write a configuration object to import. + $staging->write($name, array( + 'add_me' => 'new value', + )); + + // Verify that there are configuration differences to import. + $this->drupalGet('admin/config/development/sync'); + $this->assertNoText(t('There are no configuration changes.')); + + // Acquire a fake-lock on the import mechanism. + $lock_name = 'config_import'; + lock_acquire($lock_name); + + // Attempt to import configuration and verify that an error message appears. + $this->drupalPost(NULL, array(), t('Import all')); + $this->assertUrl('admin/config/development/sync'); + $this->assertText(t('The import failed due to an error. Any errors have been logged.')); + + // Release the lock, just to keep testing sane. + lock_release($lock_name); + } +} diff --git a/core/profiles/standard/standard.info b/core/profiles/standard/standard.info index 8b8a33b..27cff89 100644 --- a/core/profiles/standard/standard.info +++ b/core/profiles/standard/standard.info @@ -5,6 +5,7 @@ core = 8.x dependencies[] = node dependencies[] = block dependencies[] = color +dependencies[] = config dependencies[] = comment dependencies[] = contextual dependencies[] = help