diff --git a/core/includes/config.inc b/core/includes/config.inc index 5e07c54..ad8ea47 100644 --- a/core/includes/config.inc +++ b/core/includes/config.inc @@ -23,15 +23,28 @@ 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. + foreach (config_get_module_config_entities($name) as $entity_type => $entity_info) { + $manifest_config = config('manifest.' . $entity_info['config prefix']); + $manifest_data = array(); + foreach ($source_storage->listAll($entity_info['config prefix']) as $config_name) { + // @todo Figure out a better way to strip the prefix from the $config_name. Note + // that we can't use the whole $config_name here, because config keys can not + // contain the "." character. See also http://drupal.org/node/1760358. + $manifest_data[str_replace($entity_info['config prefix'] . '.', '', $config_name)] = $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); @@ -40,6 +53,28 @@ function config_install_default_config($type, $name) { } /** + * Uninstalls the default configuration of a given extension. + * + * @param string $type + * The extension type; e.g., 'module' or 'theme'. + * @param string $name + * The name of the module or theme to install default configuration for. + */ +function config_uninstall_default_config($type, $name) { + $storage = drupal_container()->get('config.storage'); + $config_names = $storage->listAll($name . '.'); + foreach ($config_names as $config_name) { + config($config_name)->delete(); + } + + // If this module defines any ConfigEntity types, then delete the manifest + // file for each of them. + foreach (config_get_module_config_entities($name) as $entity_type) { + config('manifest.' . $entity_info['config prefix'])->delete(); + } +} + +/** * Gets configuration object names starting with a given prefix. * * @see Drupal\Core\Config\StorageInterface::listAll() @@ -80,18 +115,32 @@ 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(); + $target_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_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; + } } } @@ -213,17 +262,33 @@ function config_import_invoke_owner(array $config_changes, StorageInterface $sou } /** - * Exports the active configuration to staging. + * Return a list of all config entity types provided by a module. + * + * @param string $module + * The name of the module possibly providing config entities. + * + * @return array + * An associative array containing the entity info for any config entities + * provided by the requested module, keyed by the entity type. */ -function config_export() { - // Retrieve a list of differences between the active configuration and staging. - $source_storage = drupal_container()->get('config.storage'); - $target_storage = drupal_container()->get('config.storage.staging'); - - $config_changes = config_sync_get_changes($source_storage, $target_storage); - if (empty($config_changes)) { - return; +function config_get_module_config_entities($module) { + // @todo The proper way to get this info would be to call get_entity_info() + // which adds defaults and acknowledges hook_entity_info_alter(). However, at + // this time, you can not query that info to check entities owned by a + // arbitrary module. There is a patch in the queue which would add a + // required 'module' key to the entity info which could be used to query the + // way we want to here, but until it lands the code below is our only option. + // @see http://drupal.org/node/1763974#comment-6620908 + $config_entities = array(); + if (function_exists($module . '_entity_info')) { + $info = module_invoke($module, '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'])) { + $config_entities[$entity_type] = $entity_info; + } + } } - config_sync_changes($config_changes, $source_storage, $target_storage); - return TRUE; + return $config_entities; } diff --git a/core/includes/install.inc b/core/includes/install.inc index e74e06b..b37e4dc 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -283,6 +283,21 @@ function drupal_install_config_directories() { '@handbook_url' => 'http://drupal.org/server-permissions', ))); } + + // Put a README.txt into each config directory. This is required so that + // they can later be added to git. Since these directories are auto- + // created, we have to write out the README rather than just adding it + // to the drupal core repo. + switch ($config_type) { + case CONFIG_ACTIVE_DIRECTORY: + $text = 'This directory contains the active configuration for your Drupal site. To move this configuration between environments, contents from this directory should be placed in the staging directory on the target server. To make this configuration active, see admin/config/development/sync on the target server.'; + break; + case CONFIG_STAGING_DIRECTORY: + $text = 'This directory contains configuration to be imported into your Drupal site. To make this configuration active, see admin/config/development/sync.'; + break; + } + $text .= ' For information about deploying configuration between servers, see http://drupal.org/documentation/administer/config'; + file_put_contents(config_get_config_directory($config_type) . '/README.txt', $text); } } diff --git a/core/includes/module.inc b/core/includes/module.inc index c1d99f2..efef6d5 100644 --- a/core/includes/module.inc +++ b/core/includes/module.inc @@ -695,10 +695,7 @@ function module_uninstall($module_list = array(), $uninstall_dependents = TRUE) drupal_uninstall_schema($module); // Remove all configuration belonging to the module. - $config_names = $storage->listAll($module . '.'); - foreach ($config_names as $config_name) { - config($config_name)->delete(); - } + config_uninstall_default_config('module', $module); watchdog('system', '%module module uninstalled.', array('%module' => $module), WATCHDOG_INFO); $schema_store->delete($module); diff --git a/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php b/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php index 07849c6..513e7a6 100644 --- a/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php +++ b/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php @@ -250,6 +250,11 @@ public function delete($ids) { foreach ($entities as $id => $entity) { $config = config($this->entityInfo['config prefix'] . '.' . $entity->id()); $config->delete(); + + // Remove the entity from the manifest file. + config('manifest.' . $this->entityInfo['config prefix']) + ->clear($entity->id()) + ->save(); } $this->postDelete($entities); @@ -304,6 +309,14 @@ public function save(EntityInterface $entity) { $this->invokeHook('insert', $entity); } + // Add this entity to the manifest file if necessary. + $config = config('manifest.' . $this->entityInfo['config prefix']); + $manifest = $config->get(); + if (!in_array($this->entityInfo['config prefix'] . '.' . $entity->id(), $manifest)) { + $manifest[$entity->id()] = $this->entityInfo['config prefix'] . '.' . $entity->id(); + $config->setData($manifest)->save(); + } + unset($entity->original); return $return; 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..c32e48f 100644 --- a/core/modules/config/config.module +++ b/core/modules/config/config.module @@ -1 +1,58 @@ ' . 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 your staging directory. All changes, deletions, renames, and additions are listed below.') . '

'; + 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/ConfigImportTest.php b/core/modules/config/lib/Drupal/config/Tests/ConfigImportTest.php index b659ace..9b346c3 100644 --- a/core/modules/config/lib/Drupal/config/Tests/ConfigImportTest.php +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigImportTest.php @@ -48,12 +48,6 @@ function testNoImport() { // Verify that a bare config() does not involve module APIs. $this->assertFalse(isset($GLOBALS['hook_config_test'])); - - // Export. - config_export(); - - // Verify that config_export() does not involve module APIs. - $this->assertFalse(isset($GLOBALS['hook_config_test'])); } /** @@ -71,9 +65,6 @@ function testDeleted() { $config = config($dynamic_name); $this->assertIdentical($config->get('id'), 'default'); - // Export. - config_export(); - // Delete the configuration objects from the staging directory. $staging->delete($name); $staging->delete($dynamic_name); @@ -111,9 +102,6 @@ function testNew() { $storage = $this->container->get('config.storage'); $staging = $this->container->get('config.storage.staging'); - // Export. - config_export(); - // 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.'); @@ -167,9 +155,6 @@ function testUpdated() { $storage = $this->container->get('config.storage'); $staging = $this->container->get('config.storage.staging'); - // Export. - config_export(); - // Verify that the configuration objects to import exist. $this->assertIdentical($storage->exists($name), TRUE, $name . ' found.'); $this->assertIdentical($storage->exists($dynamic_name), TRUE, $dynamic_name . ' found.'); 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