Commit 265fe4e7 authored by webchick's avatar webchick
Browse files

Issue #1697256 by heyrocker, alexpott, sun, xjm, webchick: Create a UI for...

Issue #1697256 by heyrocker, alexpott, sun, xjm, webchick: Create a UI for importing new configuration.
parent 12993dd6
......@@ -10,6 +10,11 @@
* This is the API for configuration storage.
*/
/**
* Config import lock name used to prevent concurrent synchronizations.
*/
const CONFIG_IMPORT_LOCK = 'config_import';
/**
* Installs the default configuration of a given extension.
*
......@@ -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);
......@@ -39,6 +55,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.
*
......@@ -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,8 @@ function config_import() {
watchdog_exception('config_import', $e);
$success = FALSE;
}
lock()->release(__FUNCTION__);
lock()->release(CONFIG_IMPORT_LOCK);
return $success;
}
......@@ -211,17 +277,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');
});
}
......@@ -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);
}
}
......
......@@ -705,10 +705,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);
......
......@@ -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;
......
<?php
/**
* @file
* Admin page callbacks for the config module.
*/
use Drupal\Core\Config\StorageInterface;
/**
* Helper function to construct the storage changes in a configuration synchronization form.
*
* @param array $form
* The form structure to add to. Passed by reference.
* @param array $form_state
* The current state of the form. Passed by reference.
* @param Drupal\Core\Config\StorageInterface $source_storage
* The source storage to retrieve differences from.
* @param Drupal\Core\Config\StorageInterface $target_storage
* The target storage to compare differences to.
*/
function config_admin_sync_form(array &$form, array &$form_state, StorageInterface $source_storage, StorageInterface $target_storage) {
$source_list = $source_storage->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_flush_all_caches();
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');
}
}
......@@ -3,3 +3,4 @@ description = Allows administrators to manage configuration changes.
package = Core
version = VERSION
core = 8.x
configure = admin/config/development/sync
<?php
/**
* @file
* Allows site administrators to modify configuration.
*/
/**
* Implements hook_help().
*/
function config_help($path, $arg) {
switch ($path) {
case 'admin/help#config':
$output = '';
$output .= '<h3>' . t('About') . '</h3>';
$output .= '<p>' . 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 <a href="!url">Configuration manager module</a>', array(
'!url' => 'http://drupal.org/documentation/administer/config',
)) . '</p>';
return $output;
case 'admin/config/development/sync':
$output = '';
$output .= '<p>' . t('Import configuration that is placed in your staging directory. All changes, deletions, renames, and additions are listed below.') . '</p>';
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;
}
......@@ -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);
......
<?php
/**
* @file
* Definition of Drupal\config\Tests\ConfigImportUITest.
*/
namespace Drupal\config\Tests;
use Drupal\simpletest\WebTestBase;
/**
* Tests importing configuration from files into active store.
*/
class ConfigImportUITest extends WebTestBase {
public static $modules = array('config', 'config_test');
public static function getInfo() {
return array(
'name' => '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.');