diff --git a/core/includes/config.inc b/core/includes/config.inc index ef43774..8974e54 100644 --- a/core/includes/config.inc +++ b/core/includes/config.inc @@ -11,6 +11,11 @@ */ /** + * Config import lock name used to prevent concurrent synchronizations. + */ +const CONFIG_IMPORT_LOCK = 'config_import'; + +/** * Installs the default configuration of a given extension. * * @param string $type @@ -23,15 +28,26 @@ 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) { + list(, , $id) = explode('.', $config_name); + $manifest_data[$id]['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 +56,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 +118,45 @@ 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) { + if ($source_manifest_data = $source_storage->read($name)) { + $source_config_data = array_merge($source_config_data, $source_manifest_data); + } + + if ($target_manifest_data = $target_storage->read($name)) { + $target_config_data = array_merge($target_config_data, $target_manifest_data); + } + } + $config_changes = array( - 'create' => array_diff($source_names, $target_names), + 'create' => array(), 'change' => array(), - 'delete' => array_diff($target_names, $source_names), + 'delete' => array(), ); - 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_diff_assoc($target_config_data, $source_config_data) as $name => $value) { + $config_changes['delete'][] = $value['name']; + } + + foreach (array_diff_assoc($source_config_data, $target_config_data) as $name => $value) { + $config_changes['create'][] = $value['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; + } } } @@ -144,7 +209,7 @@ function config_import() { return; } - if (!lock()->acquire(__FUNCTION__)) { + if (!lock()->acquire(CONFIG_IMPORT_LOCK)) { // Another request is synchronizing configuration. // Return a negative result for UI purposes. We do not differentiate between // an actual synchronization error and a failed lock, because concurrent @@ -162,7 +227,10 @@ function config_import() { watchdog_exception('config_import', $e); $success = FALSE; } - lock()->release(__FUNCTION__); + lock()->release(CONFIG_IMPORT_LOCK); + + drupal_flush_all_caches(); + return $success; } @@ -211,17 +279,21 @@ 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; - } - config_sync_changes($config_changes, $source_storage, $target_storage); - return TRUE; +function config_get_module_config_entities($module) { + // While this is a lot of work to generate, it's not worth static caching + // since this function is only called at install/uninstall, and only + // once per module. + $info = entity_get_info(); + return array_filter($info, function($entity_info) use ($module) { + return ($entity_info['module'] == $module) && is_subclass_of($entity_info['class'], 'Drupal\Core\Config\Entity\ConfigEntityInterface'); + }); } diff --git a/core/includes/install.inc b/core/includes/install.inc index 023e204..ee501ba 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -284,6 +284,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 75ec5b8..e550323 100644 --- a/core/includes/module.inc +++ b/core/includes/module.inc @@ -704,10 +704,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 2206dcf..6d0a3f4 100644 --- a/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php +++ b/core/lib/Drupal/Core/Config/Entity/ConfigStorageController.php @@ -253,6 +253,11 @@ public function delete(array $entities) { 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); @@ -315,6 +320,16 @@ 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()] = array( + 'name' => $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..8e14664 --- /dev/null +++ b/core/modules/config/config.admin.inc @@ -0,0 +1,114 @@ +listAll(); + if (empty($source_list)) { + $form['no_changes'] = array( + '#markup' => t('There is no configuration to import.'), + ); + return $form; + } + + $config_changes = config_sync_get_changes($source_storage, $target_storage); + if (empty($config_changes)) { + $form['no_changes'] = array( + '#markup' => 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 (!lock()->lockMayBeAvailable(CONFIG_IMPORT_LOCK)) { + drupal_set_message(t('Another request may be synchronizing configuration already.')); + } + else if (config_import()) { + // Once a sync completes, we empty the staging directory. This prevents + // changes from being accidentally overwritten by stray files getting + // imported later. + $source_storage = drupal_container()->get('config.storage.staging'); + foreach ($source_storage->listAll() as $name) { + $source_storage->delete($name); + } + + drupal_set_message(t('The configuration was imported successfully.')); + } + else { + 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..a41fc09 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/administer/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 189d13b..95089f7 100644 --- a/core/modules/config/lib/Drupal/config/Tests/ConfigImportTest.php +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigImportTest.php @@ -51,45 +51,28 @@ 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'])); } /** * Tests deletion of configuration during import. */ function testDeleted() { - $name = 'config_test.system'; $dynamic_name = 'config_test.dynamic.default'; $storage = $this->container->get('config.storage'); $staging = $this->container->get('config.storage.staging'); // Verify the default configuration values exist. - $config = config($name); - $this->assertIdentical($config->get('foo'), 'bar'); $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); - + // Create an empty manifest to delete the configuration object. + $staging->write('manifest.config_test.dynamic', array()); // Import. config_import(); // Verify the values have disappeared. - $this->assertIdentical($storage->read($name), FALSE); $this->assertIdentical($storage->read($dynamic_name), FALSE); - $config = config($name); - $this->assertIdentical($config->get('foo'), NULL); $config = config($dynamic_name); $this->assertIdentical($config->get('id'), NULL); @@ -109,26 +92,16 @@ function testDeleted() { * Tests creation of configuration during import. */ function testNew() { - $name = 'config_test.new'; $dynamic_name = 'config_test.dynamic.new'; $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.'); - $this->assertIdentical($staging->exists($name), FALSE, $name . ' not found.'); $this->assertIdentical($staging->exists($dynamic_name), FALSE, $dynamic_name . ' not found.'); - // Create new configuration objects in the staging directory. - $original_name_data = array( - 'add_me' => 'new value', - ); - $staging->write($name, $original_name_data); + // Create new config entity. $original_dynamic_data = array( 'id' => 'new', 'uuid' => '30df59bd-7b03-4cf7-bb35-d42fc49f0651', @@ -137,15 +110,18 @@ function testNew() { 'langcode' => 'und', ); $staging->write($dynamic_name, $original_dynamic_data); - $this->assertIdentical($staging->exists($name), TRUE, $name . ' found.'); + + // Create manifest for new config entity. + $manifest_data = config('manifest.config_test.dynamic')->get(); + $manifest_data[$original_dynamic_data['id']]['name'] = 'config_test.dynamic.' . $original_dynamic_data['id']; + $staging->write('manifest.config_test.dynamic', $manifest_data); + $this->assertIdentical($staging->exists($dynamic_name), TRUE, $dynamic_name . ' found.'); // Import. config_import(); // Verify the values appeared. - $config = config($name); - $this->assertIdentical($config->get('add_me'), $original_name_data['add_me']); $config = config($dynamic_name); $this->assertIdentical($config->get('label'), $original_dynamic_data['label']); @@ -170,25 +146,22 @@ 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.'); - $this->assertIdentical($staging->exists($name), TRUE, $name . ' found.'); - $this->assertIdentical($staging->exists($dynamic_name), TRUE, $dynamic_name . ' found.'); - // Replace the file content of the existing configuration objects in the // staging directory. $original_name_data = array( 'foo' => 'beer', ); $staging->write($name, $original_name_data); - $original_dynamic_data = $staging->read($dynamic_name); + $original_dynamic_data = $storage->read($dynamic_name); $original_dynamic_data['label'] = 'Updated'; $staging->write($dynamic_name, $original_dynamic_data); + // Create manifest for updated config entity. + $manifest_data = config('manifest.config_test.dynamic')->get(); + $staging->write('manifest.config_test.dynamic', $manifest_data); // Verify the active configuration still returns the default values. $config = config($name); 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..0fad980 --- /dev/null +++ b/core/modules/config/lib/Drupal/config/Tests/ConfigImportUITest.php @@ -0,0 +1,128 @@ + 'Import 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 = 'system.site'; + $dynamic_name = 'config_test.dynamic.new'; + $storage = $this->container->get('config.storage'); + $staging = $this->container->get('config.storage.staging'); + + // Verify the configuration to create and update does not exist yet. + $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 recognises that the staging folder is empty. + $this->drupalGet('admin/config/development/sync'); + $this->assertText('There is no configuration to import.'); + + // Create updated configuration object. + $new_site_name = 'Config import test ' . $this->randomString(); + $this->prepareSiteNameUpdate($new_site_name); + $this->assertIdentical($staging->exists($name), TRUE, $name . ' found.'); + + // Create new config entity. + $original_dynamic_data = array( + 'id' => 'new', + 'uuid' => '30df59bd-7b03-4cf7-bb35-d42fc49f0651', + 'label' => 'New', + 'style' => '', + 'langcode' => 'und', + ); + $staging->write($dynamic_name, $original_dynamic_data); + + // Create manifest for new config entity. + $manifest_data = config('manifest.config_test.dynamic')->get(); + $manifest_data[$original_dynamic_data['id']]['name'] = 'config_test.dynamic.' . $original_dynamic_data['id']; + $staging->write('manifest.config_test.dynamic', $manifest_data); + + $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 is no configuration to import.')); + + // Verify site name has changed. + $this->assertIdentical($new_site_name, config('system.site')->get('name')); + + // Verify that new config entity exists. + $this->assertIdentical($original_dynamic_data, config($dynamic_name)->get()); + } + + /** + * Tests concurrent importing of configuration. + */ + function testImportLock() { + // Create updated configuration object. + $new_site_name = 'Config import test ' . $this->randomString(); + $this->prepareSiteNameUpdate($new_site_name); + + // 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()->acquire('config_import'); + + // 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('Another request may be synchronizing configuration already.')); + + // Release the lock, just to keep testing sane. + lock()->release('config_import'); + + // Verify site name has not changed. + $this->assertNotEqual($new_site_name, config('system.site')->get('name')); + } + + function prepareSiteNameUpdate($new_site_name) { + $staging = $this->container->get('config.storage.staging'); + // Create updated configuration object. + $config_data = config('system.site')->get(); + $config_data['name'] = $new_site_name; + $staging->write('system.site', $config_data); + } +} diff --git a/core/modules/views/views.module b/core/modules/views/views.module index 6608d47..9249772 100644 --- a/core/modules/views/views.module +++ b/core/modules/views/views.module @@ -2188,3 +2188,33 @@ function views_array_key_plus($array) { asort($array); return $array; } + +/** + * Implements hook_config_import_create(). + */ +function views_config_import_create($name, $new_config, $old_config) { + // Only image styles require custom handling. Any other module settings can be + // synchronized directly. + if (strpos($name, 'views.view.') !== 0) { + return FALSE; + } + $view = entity_create('view', $new_config->get()); + $view->save(); + return TRUE; +} + +/** + * Implements MODULE_config_import_delete(). + */ +function views_config_import_delete($name, $new_config, $old_config) { + // Only image styles require custom handling. Any other module settings can be + // synchronized directly. + if (strpos($name, 'views.view.') !== 0) { + return FALSE; + } + + list(, , $id) = explode('.', $name); + $view = entity_load('view', $id); + entity_delete($view); + return TRUE; +} diff --git a/core/profiles/standard/standard.info b/core/profiles/standard/standard.info index 18136bc..4c812d0 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