diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc index c608052cfb6e50af3c1927b2149a0423adbb092f..f2a5a6a64d73953d3d4566b2391b94705c657ea6 100644 --- a/core/includes/install.core.inc +++ b/core/includes/install.core.inc @@ -6,6 +6,11 @@ */ use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Batch\BatchBuilder; +use Drupal\Core\Config\ConfigImporter; +use Drupal\Core\Config\ConfigImporterException; +use Drupal\Core\Config\Importer\ConfigImporterBatch; +use Drupal\Core\Config\StorageComparer; use Drupal\Core\DrupalKernel; use Drupal\Core\Database\Database; use Drupal\Core\Database\DatabaseExceptionWrapper; @@ -198,6 +203,10 @@ function install_state_defaults() { // The last task that was completed during the previous installation // request. 'completed_task' => NULL, + // Partial configuration cached during an installation from existing config. + 'config' => NULL, + // The path to the configuration to install when installing from config. + 'config_install_path' => NULL, // TRUE when there are valid config directories. 'config_verified' => FALSE, // TRUE when there is a valid database connection. @@ -473,9 +482,13 @@ function install_begin_request($class_loader, &$install_state) { // @todo Remove as part of https://www.drupal.org/node/2186491 drupal_get_filename('module', 'system', 'core/modules/system/system.info.yml'); - // Use the language from the profile configuration, if available, to override - // the language previously set in the parameters. - if (isset($install_state['profile_info']['distribution']['langcode'])) { + // Use the language from profile configuration if available. + if (!empty($install_state['config_install_path']) && $install_state['config']['system.site']) { + $install_state['parameters']['langcode'] = $install_state['config']['system.site']['default_langcode']; + } + elseif (isset($install_state['profile_info']['distribution']['langcode'])) { + // Otherwise, Use the language from the profile configuration, if available, + // to override the language previously set in the parameters. $install_state['parameters']['langcode'] = $install_state['profile_info']['distribution']['langcode']; } @@ -818,6 +831,30 @@ function install_tasks($install_state) { ], ]; + if (!empty($install_state['config_install_path'])) { + // The chosen profile indicates that rather than installing a new site, an + // instance of the same site should be installed from the given + // configuration. + // That means we need to remove the steps installing the extensions and + // replace them with a configuration synchronization step. + unset($tasks['install_download_translation']); + $key = array_search('install_profile_modules', array_keys($tasks), TRUE); + unset($tasks['install_profile_modules']); + unset($tasks['install_profile_themes']); + unset($tasks['install_install_profile']); + $config_tasks = [ + 'install_config_import_batch' => [ + 'display_name' => t('Install configuration'), + 'type' => 'batch', + ], + 'install_config_download_translations' => [], + 'install_config_revert_install_changes' => [], + ]; + $tasks = array_slice($tasks, 0, $key, TRUE) + + $config_tasks + + array_slice($tasks, $key, NULL, TRUE); + } + // Now add any tasks defined by the installation profile. if (!empty($install_state['parameters']['profile'])) { // Load the profile install file, because it is not always loaded when @@ -1494,6 +1531,14 @@ function install_load_profile(&$install_state) { $profile = $install_state['parameters']['profile']; $install_state['profiles'][$profile]->load(); $install_state['profile_info'] = install_profile_info($profile, isset($install_state['parameters']['langcode']) ? $install_state['parameters']['langcode'] : 'en'); + // If the profile has a config/sync directory copy the information to the + // install_state global. + if (!empty($install_state['profile_info']['config_install_path'])) { + $install_state['config_install_path'] = $install_state['profile_info']['config_install_path']; + if (!empty($install_state['profile_info']['config'])) { + $install_state['config'] = $install_state['profile_info']['config']; + } + } } /** @@ -2260,3 +2305,134 @@ function install_write_profile($install_state) { throw new InstallProfileMismatchException($install_state['parameters']['profile'], $settings_profile, $settings_path, \Drupal::translation()); } } + +/** + * Creates a batch for the config importer to process. + * + * @see install_tasks() + */ +function install_config_import_batch() { + // We need to manually trigger the installation of core-provided entity types, + // as those will not be handled by the module installer. + // @see install_profile_modules() + install_core_entity_type_definitions(); + + // Get the sync storage. + $sync = \Drupal::service('config.storage.sync'); + // Match up the site UUIDs, the install_base_system install task will have + // installed the system module and created a new UUID. + $system_site = $sync->read('system.site'); + \Drupal::configFactory()->getEditable('system.site')->set('uuid', $system_site['uuid'])->save(); + + // Create the storage comparer and the config importer. + $config_manager = \Drupal::service('config.manager'); + $storage_comparer = new StorageComparer($sync, \Drupal::service('config.storage'), $config_manager); + $storage_comparer->createChangelist(); + $config_importer = new ConfigImporter( + $storage_comparer, + \Drupal::service('event_dispatcher'), + $config_manager, + \Drupal::service('lock.persistent'), + \Drupal::service('config.typed'), + \Drupal::service('module_handler'), + \Drupal::service('module_installer'), + \Drupal::service('theme_handler'), + \Drupal::service('string_translation') + ); + + try { + $sync_steps = $config_importer->initialize(); + + $batch_builder = new BatchBuilder(); + $batch_builder + ->setFinishCallback([ConfigImporterBatch::class, 'finish']) + ->setTitle(t('Importing configuration')) + ->setInitMessage(t('Starting configuration import.')) + ->setErrorMessage(t('Configuration import has encountered an error.')); + + foreach ($sync_steps as $sync_step) { + $batch_builder->addOperation([ConfigImporterBatch::class, 'process'], [$config_importer, $sync_step]); + } + + return $batch_builder->toArray(); + } + catch (ConfigImporterException $e) { + global $install_state; + // There are validation errors. + $messenger = \Drupal::messenger(); + $messenger->addError(t('The configuration synchronization failed validation.')); + foreach ($config_importer->getErrors() as $message) { + $messenger->addError($message); + } + install_display_output(['#title' => t('Configuration validation')], $install_state); + } +} + +/** + * Replaces install_download_translation() during configuration installs. + * + * @param array $install_state + * An array of information about the current installation state. + * + * @return string + * A themed status report, or an exception if there are requirement errors. + * Upon successful download the page is reloaded and no output is returned. + * + * @see install_download_translation() + */ +function install_config_download_translations(&$install_state) { + $needs_download = isset($install_state['parameters']['langcode']) && !isset($install_state['translations'][$install_state['parameters']['langcode']]) && $install_state['parameters']['langcode'] !== 'en'; + if ($needs_download) { + return install_download_translation($install_state); + } +} + +/** + * Reverts configuration if hook_install() implementations have made changes. + * + * This step ensures that the final configuration matches the configuration + * provided to the installer. + */ +function install_config_revert_install_changes() { + global $install_state; + + $config_manager = \Drupal::service('config.manager'); + $storage_comparer = new StorageComparer(\Drupal::service('config.storage.sync'), \Drupal::service('config.storage'), $config_manager); + $storage_comparer->createChangelist(); + if ($storage_comparer->hasChanges()) { + $config_importer = new ConfigImporter( + $storage_comparer, + \Drupal::service('event_dispatcher'), + $config_manager, + \Drupal::service('lock.persistent'), + \Drupal::service('config.typed'), + \Drupal::service('module_handler'), + \Drupal::service('module_installer'), + \Drupal::service('theme_handler'), + \Drupal::service('string_translation') + ); + try { + $config_importer->import(); + } + catch (ConfigImporterException $e) { + global $install_state; + $messenger = \Drupal::messenger(); + // There are validation errors. + $messenger->addError(t('The configuration synchronization failed validation.')); + foreach ($config_importer->getErrors() as $message) { + $messenger->addError($message); + } + install_display_output(['#title' => t('Configuration validation')], $install_state); + } + + // At this point the configuration should match completely. + if (\Drupal::moduleHandler()->moduleExists('language')) { + // If the English language exists at this point we need to ensure + // install_download_additional_translations_operations() does not delete + // it. + if (ConfigurableLanguage::load('en')) { + $install_state['profile_info']['keep_english'] = TRUE; + } + } + } +} diff --git a/core/includes/install.inc b/core/includes/install.inc index 56b450ec95c150aafacaa306b77e4ffd0e7b6c25..d65c85fff971eef31d0688668f56be8ded94429c 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -10,6 +10,7 @@ use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\OpCodeCache; use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Config\FileStorage; use Drupal\Core\Extension\ExtensionDiscovery; use Drupal\Core\Site\Settings; @@ -481,12 +482,20 @@ function _drupal_rewrite_settings_dump_one(\stdClass $variable, $prefix = '', $s * @see update_prepare_d8_bootstrap() */ function drupal_install_config_directories() { - global $config_directories; + global $config_directories, $install_state; - // Add a randomized config directory name to settings.php, unless it was - // manually defined in the existing already. + // If settings.php does not contain a config sync directory name we need to + // configure one. if (empty($config_directories[CONFIG_SYNC_DIRECTORY])) { - $config_directories[CONFIG_SYNC_DIRECTORY] = \Drupal::service('site.path') . '/files/config_' . Crypt::randomBytesBase64(55) . '/sync'; + if (empty($install_state['config_install_path'])) { + // Add a randomized config directory name to settings.php + $config_directories[CONFIG_SYNC_DIRECTORY] = \Drupal::service('site.path') . '/files/config_' . Crypt::randomBytesBase64(55) . '/sync'; + } + else { + // Install profiles can contain a config sync directory. If they do, + // 'config_install_path' is a path to the directory. + $config_directories[CONFIG_SYNC_DIRECTORY] = $install_state['config_install_path']; + } $settings['config_directories'][CONFIG_SYNC_DIRECTORY] = (object) [ 'value' => $config_directories[CONFIG_SYNC_DIRECTORY], 'required' => TRUE, @@ -1099,9 +1108,10 @@ function install_profile_info($profile, $langcode = 'en') { 'version' => NULL, 'hidden' => FALSE, 'php' => DRUPAL_MINIMUM_PHP, + 'config_install_path' => NULL, ]; - $profile_file = drupal_get_path('profile', $profile) . "/$profile.info.yml"; - $info = \Drupal::service('info_parser')->parse($profile_file); + $profile_path = drupal_get_path('profile', $profile); + $info = \Drupal::service('info_parser')->parse("$profile_path/$profile.info.yml"); $info += $defaults; // drupal_required_modules() includes the current profile as a dependency. @@ -1114,6 +1124,12 @@ function install_profile_info($profile, $langcode = 'en') { // remove any duplicates. $info['install'] = array_unique(array_merge($info['install'], $required, $info['dependencies'], $locale)); + // If the profile has a config/sync directory use that to install drupal. + if (is_dir($profile_path . '/config/sync')) { + $info['config_install_path'] = $profile_path . '/config/sync'; + $sync = new FileStorage($profile_path . '/config/sync'); + $info['config']['system.site'] = $sync->read('system.site'); + } $cache[$profile][$langcode] = $info; } return $cache[$profile][$langcode]; diff --git a/core/lib/Drupal/Core/Config/ConfigImporter.php b/core/lib/Drupal/Core/Config/ConfigImporter.php index b58f96358ba72636ac775d69fb39e00ca8140989..c09372775068c5bdbdcd61a8c81f4de963a79ee7 100644 --- a/core/lib/Drupal/Core/Config/ConfigImporter.php +++ b/core/lib/Drupal/Core/Config/ConfigImporter.php @@ -405,6 +405,14 @@ protected function createExtensionChangelist() { $module_list = array_reverse($module_list); $this->extensionChangelist['module']['install'] = array_intersect(array_keys($module_list), $install); + // If we're installing the install profile ensure it comes last. This will + // occur when installing a site from configuration. + $install_profile_key = array_search($new_extensions['profile'], $this->extensionChangelist['module']['install'], TRUE); + if ($install_profile_key !== FALSE) { + unset($this->extensionChangelist['module']['install'][$install_profile_key]); + $this->extensionChangelist['module']['install'][] = $new_extensions['profile']; + } + // Work out what themes to install and to uninstall. $this->extensionChangelist['theme']['install'] = array_keys(array_diff_key($new_extensions['theme'], $current_extensions['theme'])); $this->extensionChangelist['theme']['uninstall'] = array_keys(array_diff_key($current_extensions['theme'], $new_extensions['theme'])); diff --git a/core/lib/Drupal/Core/Config/Importer/ConfigImporterBatch.php b/core/lib/Drupal/Core/Config/Importer/ConfigImporterBatch.php new file mode 100644 index 0000000000000000000000000000000000000000..8aee289e0d14b32f9409c94ce8343be171ae4d09 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Importer/ConfigImporterBatch.php @@ -0,0 +1,77 @@ +doSyncStep($sync_step, $context); + if ($errors = $config_importer->getErrors()) { + if (!isset($context['results']['errors'])) { + $context['results']['errors'] = []; + } + $context['results']['errors'] = array_merge($errors, $context['results']['errors']); + } + } + + /** + * Finish batch. + * + * This function is a static function to avoid serializing the ConfigSync + * object unnecessarily. + * + * @param bool $success + * Indicate that the batch API tasks were all completed successfully. + * @param array $results + * An array of all the results that were updated in update_do_one(). + * @param array $operations + * A list of the operations that had not been completed by the batch API. + */ + public static function finish($success, $results, $operations) { + $messenger = \Drupal::messenger(); + if ($success) { + if (!empty($results['errors'])) { + $logger = \Drupal::logger('config_sync'); + foreach ($results['errors'] as $error) { + $messenger->addError($error); + $logger->error($error); + } + $messenger->addWarning(t('The configuration was imported with errors.')); + } + elseif (!drupal_installation_attempted()) { + // Display a success message when not installing Drupal. + $messenger->addStatus(t('The configuration was imported successfully.')); + } + } + else { + // An error occurred. + // $operations contains the operations that remained unprocessed. + $error_operation = reset($operations); + $message = t('An error occurred while processing %error_operation with arguments: @arguments', ['%error_operation' => $error_operation[0], '@arguments' => print_r($error_operation[1], TRUE)]); + $messenger->addError($message); + } + } + +} diff --git a/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php b/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php index d5f8f56a791d66b712bb4183d1a39b341727d535..2cca962599934cae8bf9be19ca85be15122031d9 100644 --- a/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php +++ b/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php @@ -121,6 +121,7 @@ protected function getEditableConfigNames() { * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { + global $install_state; $form['#title'] = $this->t('Configure site'); // Warn about settings.php permissions risk @@ -148,12 +149,14 @@ public function buildForm(array $form, FormStateInterface $form_state) { $form['site_information'] = [ '#type' => 'fieldgroup', '#title' => $this->t('Site information'), + '#access' => empty($install_state['config_install_path']), ]; $form['site_information']['site_name'] = [ '#type' => 'textfield', '#title' => $this->t('Site name'), '#required' => TRUE, '#weight' => -20, + '#access' => empty($install_state['config_install_path']), ]; $form['site_information']['site_mail'] = [ '#type' => 'email', @@ -162,6 +165,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#description' => $this->t("Automated emails, such as registration information, will be sent from this address. Use an address ending in your site's domain to help prevent these emails from being flagged as spam."), '#required' => TRUE, '#weight' => -15, + '#access' => empty($install_state['config_install_path']), ]; $form['admin_account'] = [ @@ -191,6 +195,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { $form['regional_settings'] = [ '#type' => 'fieldgroup', '#title' => $this->t('Regional settings'), + '#access' => empty($install_state['config_install_path']), ]; $countries = $this->countryManager->getList(); $form['regional_settings']['site_default_country'] = [ @@ -201,6 +206,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#options' => $countries, '#description' => $this->t('Select the default country for the site.'), '#weight' => 0, + '#access' => empty($install_state['config_install_path']), ]; $form['regional_settings']['date_default_timezone'] = [ '#type' => 'select', @@ -211,17 +217,20 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#description' => $this->t('By default, dates in this site will be displayed in the chosen time zone.'), '#weight' => 5, '#attributes' => ['class' => ['timezone-detect']], + '#access' => empty($install_state['config_install_path']), ]; $form['update_notifications'] = [ '#type' => 'fieldgroup', '#title' => $this->t('Update notifications'), '#description' => $this->t('The system will notify you when updates and important security releases are available for installed components. Anonymous information about your site is sent to Drupal.org.', [':drupal' => 'https://www.drupal.org']), + '#access' => empty($install_state['config_install_path']), ]; $form['update_notifications']['enable_update_status_module'] = [ '#type' => 'checkbox', '#title' => $this->t('Check for updates automatically'), '#default_value' => 1, + '#access' => empty($install_state['config_install_path']), ]; $form['update_notifications']['enable_update_status_emails'] = [ '#type' => 'checkbox', @@ -232,6 +241,7 @@ public function buildForm(array $form, FormStateInterface $form_state) { 'input[name="enable_update_status_module"]' => ['checked' => TRUE], ], ], + '#access' => empty($install_state['config_install_path']), ]; $form['actions'] = ['#type' => 'actions']; @@ -258,21 +268,25 @@ public function validateForm(array &$form, FormStateInterface $form_state) { * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { - $this->config('system.site') - ->set('name', (string) $form_state->getValue('site_name')) - ->set('mail', (string) $form_state->getValue('site_mail')) - ->save(TRUE); + global $install_state; - $this->config('system.date') - ->set('timezone.default', (string) $form_state->getValue('date_default_timezone')) - ->set('country.default', (string) $form_state->getValue('site_default_country')) - ->save(TRUE); + if (empty($install_state['config_install_path'])) { + $this->config('system.site') + ->set('name', (string) $form_state->getValue('site_name')) + ->set('mail', (string) $form_state->getValue('site_mail')) + ->save(TRUE); + + $this->config('system.date') + ->set('timezone.default', (string) $form_state->getValue('date_default_timezone')) + ->set('country.default', (string) $form_state->getValue('site_default_country')) + ->save(TRUE); + } $account_values = $form_state->getValue('account'); // Enable update.module if this option was selected. $update_status_module = $form_state->getValue('enable_update_status_module'); - if ($update_status_module) { + if (empty($install_state['config_install_path']) && $update_status_module) { $this->moduleInstaller->install(['file', 'update'], FALSE); // Add the site maintenance account's email address to the list of diff --git a/core/modules/config/src/Form/ConfigSync.php b/core/modules/config/src/Form/ConfigSync.php index 73c7a1b8b1ad7ff8fbf465fe1457fa6452d469ca..57fb1d860e000cfe74e59d9bcc8f2e74151328d2 100644 --- a/core/modules/config/src/Form/ConfigSync.php +++ b/core/modules/config/src/Form/ConfigSync.php @@ -4,6 +4,7 @@ use Drupal\Core\Config\ConfigImporterException; use Drupal\Core\Config\ConfigImporter; +use Drupal\Core\Config\Importer\ConfigImporterBatch; use Drupal\Core\Config\TypedConfigManagerInterface; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Extension\ModuleInstallerInterface; @@ -337,14 +338,14 @@ public function submitForm(array &$form, FormStateInterface $form_state) { $sync_steps = $config_importer->initialize(); $batch = [ 'operations' => [], - 'finished' => [get_class($this), 'finishBatch'], + 'finished' => [ConfigImporterBatch::class, 'finish'], 'title' => t('Synchronizing configuration'), 'init_message' => t('Starting configuration synchronization.'), 'progress_message' => t('Completed step @current of @total.'), 'error_message' => t('Configuration synchronization has encountered an error.'), ]; foreach ($sync_steps as $sync_step) { - $batch['operations'][] = [[get_class($this), 'processBatch'], [$config_importer, $sync_step]]; + $batch['operations'][] = [[ConfigImporterBatch::class, 'process'], [$config_importer, $sync_step]]; } batch_set($batch); @@ -368,20 +369,15 @@ public function submitForm(array &$form, FormStateInterface $form_state) { * The synchronization step to do. * @param array $context * The batch context. + * + * @deprecated in Drupal 8.6.0 and will be removed before 9.0.0. Use + * \Drupal\Core\Config\Importer\ConfigImporterBatch::process() instead. + * + * @see https://www.drupal.org/node/2897299 */ public static function processBatch(ConfigImporter $config_importer, $sync_step, &$context) { - if (!isset($context['sandbox']['config_importer'])) { - $context['sandbox']['config_importer'] = $config_importer; - } - - $config_importer = $context['sandbox']['config_importer']; - $config_importer->doSyncStep($sync_step, $context); - if ($errors = $config_importer->getErrors()) { - if (!isset($context['results']['errors'])) { - $context['results']['errors'] = []; - } - $context['results']['errors'] = array_merge($context['results']['errors'], $errors); - } + @trigger_error('\Drupal\config\Form\ConfigSync::processBatch() deprecated in Drupal 8.6.0 and will be removed before 9.0.0. Use \Drupal\Core\Config\Importer\ConfigImporterBatch::process() instead. See https://www.drupal.org/node/2897299'); + ConfigImporterBatch::process($config_importer, $sync_step, $context); } /** @@ -389,27 +385,15 @@ public static function processBatch(ConfigImporter $config_importer, $sync_step, * * This function is a static function to avoid serializing the ConfigSync * object unnecessarily. + * + * @deprecated in Drupal 8.6.0 and will be removed before 9.0.0. Use + * \Drupal\Core\Config\Importer\ConfigImporterBatch::finish() instead. + * + * @see https://www.drupal.org/node/2897299 */ public static function finishBatch($success, $results, $operations) { - if ($success) { - if (!empty($results['errors'])) { - foreach ($results['errors'] as $error) { - \Drupal::messenger()->addError($error); - \Drupal::logger('config_sync')->error($error); - } - \Drupal::messenger()->addWarning(\Drupal::translation()->translate('The configuration was imported with errors.')); - } - else { - \Drupal::messenger()->addStatus(\Drupal::translation()->translate('The configuration was imported successfully.')); - } - } - else { - // An error occurred. - // $operations contains the operations that remained unprocessed. - $error_operation = reset($operations); - $message = \Drupal::translation()->translate('An error occurred while processing %error_operation with arguments: @arguments', ['%error_operation' => $error_operation[0], '@arguments' => print_r($error_operation[1], TRUE)]); - \Drupal::messenger()->addError($message); - } + @trigger_error('\Drupal\config\Form\ConfigSync::finishBatch() deprecated in Drupal 8.6.0 and will be removed before 9.0.0. Use \Drupal\Core\Config\Importer\ConfigImporterBatch::finish() instead. See https://www.drupal.org/node/2897299'); + ConfigImporterBatch::finish($success, $results, $operations); } } diff --git a/core/modules/config/tests/src/Kernel/ConfigUninstallViaCliImportTest.php b/core/modules/config/tests/src/Kernel/ConfigUninstallViaCliImportTest.php index 5f77447f316f0f4e0d76655199d25dcb6aebca8b..0ab0fd1764e8068c68c8fb23f1db943f6f4a52de 100644 --- a/core/modules/config/tests/src/Kernel/ConfigUninstallViaCliImportTest.php +++ b/core/modules/config/tests/src/Kernel/ConfigUninstallViaCliImportTest.php @@ -32,6 +32,7 @@ protected function setUp() { $this->markTestSkipped('This test has to be run from the CLI'); } + $this->installConfig(['system']); $this->copyConfig($this->container->get('config.storage'), $this->container->get('config.storage.sync')); // Set up the ConfigImporter object for testing. diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php index ee4392738bf3b5d5228ff390983e6b6c246a3a9b..1b2125b7dd5387e58c6e9985bdd4cc388b58c1f3 100644 --- a/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationWorkflowConfigTest.php @@ -59,7 +59,7 @@ protected function setUp() { $this->installEntitySchema('node'); $this->installEntitySchema('user'); $this->installEntitySchema('content_moderation_state'); - $this->installConfig('content_moderation'); + $this->installConfig(['system', 'content_moderation']); NodeType::create([ 'type' => 'example', diff --git a/core/modules/content_translation/tests/src/Kernel/ContentTranslationConfigImportTest.php b/core/modules/content_translation/tests/src/Kernel/ContentTranslationConfigImportTest.php index 44e816089ec1db2bf8dc335e4274940c17a95267..0fe6b9f6e0b33985dbf7e24fd63ab00d9b922731 100644 --- a/core/modules/content_translation/tests/src/Kernel/ContentTranslationConfigImportTest.php +++ b/core/modules/content_translation/tests/src/Kernel/ContentTranslationConfigImportTest.php @@ -33,6 +33,7 @@ class ContentTranslationConfigImportTest extends KernelTestBase { protected function setUp() { parent::setUp(); + $this->installConfig(['system']); $this->installEntitySchema('entity_test_mul'); $this->copyConfig($this->container->get('config.storage'), $this->container->get('config.storage.sync')); diff --git a/core/modules/node/tests/src/Kernel/Config/NodeImportChangeTest.php b/core/modules/node/tests/src/Kernel/Config/NodeImportChangeTest.php index e1b17f2c1c9a60e3ac90efe75d694f25bc06bd76..d670e6a44cae81d23084fd28ca53c9233b044e20 100644 --- a/core/modules/node/tests/src/Kernel/Config/NodeImportChangeTest.php +++ b/core/modules/node/tests/src/Kernel/Config/NodeImportChangeTest.php @@ -26,7 +26,7 @@ protected function setUp() { parent::setUp(); // Set default storage backend. - $this->installConfig(['field', 'node_test_config']); + $this->installConfig(['system', 'field', 'node_test_config']); } /** diff --git a/core/modules/node/tests/src/Kernel/Config/NodeImportCreateTest.php b/core/modules/node/tests/src/Kernel/Config/NodeImportCreateTest.php index cb985173ae96c9eeeafe005a17b4e9f0abf1939c..0b27bc2a22c784dbda4147f9aac8dfd97a21862b 100644 --- a/core/modules/node/tests/src/Kernel/Config/NodeImportCreateTest.php +++ b/core/modules/node/tests/src/Kernel/Config/NodeImportCreateTest.php @@ -28,7 +28,7 @@ protected function setUp() { $this->installEntitySchema('user'); // Set default storage backend. - $this->installConfig(['field']); + $this->installConfig(['system', 'field']); } /** diff --git a/core/modules/system/src/SystemConfigSubscriber.php b/core/modules/system/src/SystemConfigSubscriber.php index 519155b8497c9e92a29e2aa3d85b6f867abc540a..0ab0d15fc6105dd9725adfadf09edf2d36daf14f 100644 --- a/core/modules/system/src/SystemConfigSubscriber.php +++ b/core/modules/system/src/SystemConfigSubscriber.php @@ -72,6 +72,9 @@ public function onConfigImporterValidateNotEmpty(ConfigImporterEvent $event) { * The config import event. */ public function onConfigImporterValidateSiteUUID(ConfigImporterEvent $event) { + if (!$event->getConfigImporter()->getStorageComparer()->getSourceStorage()->exists('system.site')) { + $event->getConfigImporter()->logError($this->t('This import does not contain system.site configuration, so has been rejected.')); + } if (!$event->getConfigImporter()->getStorageComparer()->validateSiteUuid()) { $event->getConfigImporter()->logError($this->t('Site UUID in source storage does not match the target storage.')); } diff --git a/core/modules/system/system.install b/core/modules/system/system.install index 3ad360ff242e54b22772e83196f7e581bdc1db50..0fe129b0b6d000f1f156734d24508a1b2b8e59f1 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -1002,6 +1002,19 @@ function system_requirements($phase) { ]; } + // During installs from configuration don't support install profiles that + // implement hook_install. + if ($phase == 'install' && !empty($install_state['config_install_path'])) { + $install_hook = $install_state['parameters']['profile'] . '_install'; + if (function_exists($install_hook)) { + $requirements['config_install'] = [ + 'title' => t('Configuration install'), + 'value' => $install_state['parameters']['profile'], + 'description' => t('The selected profile has a hook_install() implementation and therefore can not be installed from configuration.'), + 'severity' => REQUIREMENT_ERROR, + ]; + } + } return $requirements; } diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigMultilingualTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigMultilingualTest.php new file mode 100644 index 0000000000000000000000000000000000000000..db05d32031e6c7f3109184217f26e489f472473b --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigMultilingualTest.php @@ -0,0 +1,24 @@ +assertTitle('Configuration validation | Drupal'); + $this->assertText('The configuration synchronization failed validation.'); + $this->assertText('This import is empty and if applied would delete all of your configuration, so has been rejected.'); + + // Ensure there is no continuation button. + $this->assertNoText('Save and continue'); + $this->assertNoFieldById('edit-submit'); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigNoSystemSiteTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigNoSystemSiteTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4ac68d4e8637c48883e872b796bc3ebc819de984 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigNoSystemSiteTest.php @@ -0,0 +1,49 @@ +siteDirectory . '/profiles/' . $this->profile . '/config/sync/system.site.yml'); + } + + /** + * {@inheritdoc} + */ + public function setUpSite() { + return; + } + + /** + * Tests that profiles with no system.site do not work. + */ + public function testConfigSync() { + $this->htmlOutput(NULL); + $this->assertTitle('Configuration validation | Drupal'); + $this->assertText('The configuration synchronization failed validation.'); + $this->assertText('This import does not contain system.site configuration, so has been rejected.'); + + // Ensure there is no continuation button. + $this->assertNoText('Save and continue'); + $this->assertNoFieldById('edit-submit'); + } + + /** + * {@inheritdoc} + */ + protected function getConfigTarball() { + return __DIR__ . '/../../../fixtures/config_install/testing_config_install.tar.gz'; + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigProfileHookInstall.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigProfileHookInstall.php new file mode 100644 index 0000000000000000000000000000000000000000..2d9d6cdfc121c4480fecdf4e60047033456619e5 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigProfileHookInstall.php @@ -0,0 +1,64 @@ +siteDirectory . '/profiles/' . $this->profile; + $contents = <<profile}.install", $contents); + parent::visitInstaller(); + } + + /** + * Installer step: Configure settings. + */ + protected function setUpSettings() { + // There are errors therefore there is nothing to do here. + return; + } + + /** + * Final installer step: Configure site. + */ + protected function setUpSite() { + // There are errors therefore there is nothing to do here. + return; + } + + /** + * {@inheritdoc} + */ + protected function getConfigTarball() { + // We're not going to get to the config import stage so this does not + // matter. + return __DIR__ . '/../../../fixtures/config_install/testing_config_install_no_config.tar.gz'; + } + + /** + * Confirms the installation has failed and the expected error is displayed. + */ + public function testConfigSync() { + $this->assertTitle('Requirements problem | Drupal'); + $this->assertText($this->profile); + $this->assertText('The selected profile has a hook_install() implementation and therefore can not be installed from configuration.'); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ebf4c2a8c5b4e9a92aed251bccee73e190d4d090 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTest.php @@ -0,0 +1,30 @@ +translations['Save and continue'] = 'Enregistrer et continuer'; + parent::setUpSite(); + } + + /** + * {@inheritdoc} + */ + protected function getConfigTarball() { + return __DIR__ . '/../../../fixtures/config_install/testing_config_install.tar.gz'; + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..e093be966769d1f3175fe25b7b76730e0cfe2c66 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php @@ -0,0 +1,99 @@ +getConfigTarball(), 'gz'); + + if ($this->profile === NULL) { + $core_extension = Yaml::decode($archiver->extractInString('core.extension.yml')); + $this->profile = $core_extension['profile']; + } + + // Create a profile for testing. + $info = [ + 'type' => 'profile', + 'core' => \Drupal::CORE_COMPATIBILITY, + 'name' => 'Configuration installation test profile (' . $this->profile . ')', + ]; + // File API functions are not available yet. + $path = $this->siteDirectory . '/profiles/' . $this->profile; + + mkdir($path, 0777, TRUE); + file_put_contents("$path/{$this->profile}.info.yml", Yaml::encode($info)); + + // Create config/sync directory and extract tarball contents to it. + $config_sync_directory = $path . '/config/sync'; + mkdir($config_sync_directory, 0777, TRUE); + $files = []; + $list = $archiver->listContent(); + if (is_array($list)) { + /** @var array $list */ + foreach ($list as $file) { + $files[] = $file['filename']; + } + $archiver->extractList($files, $config_sync_directory); + } + } + + /** + * Gets the filepath to the configuration tarball. + * + * The tarball will be extracted to the install profile's config/sync + * directory for testing. + * + * @return string + * The filepath to the configuration tarball. + */ + abstract protected function getConfigTarball(); + + /** + * {@inheritdoc} + */ + protected function installParameters() { + $parameters = parent::installParameters(); + + // The options that change configuration are disabled when installing from + // existing configuration. + unset($parameters['forms']['install_configure_form']['site_name']); + unset($parameters['forms']['install_configure_form']['site_mail']); + unset($parameters['forms']['install_configure_form']['update_status_module']); + + return $parameters; + } + + /** + * Confirms that the installation installed the configuration correctly. + */ + public function testConfigSync() { + // After installation there is no snapshot and nothing to import. + $change_list = $this->configImporter()->getStorageComparer()->getChangelist(); + $expected = [ + 'create' => [], + // The system.mail is changed configuration because the test system + // changes it to ensure that mails are not sent. + 'update' => ['system.mail'], + 'delete' => [], + 'rename' => [], + ]; + $this->assertEqual($expected, $change_list); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigImportRecreateTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigImportRecreateTest.php index 7d9c9ae23f763d17d73c60ed94d1b2f69ab8678e..fef464ed7f21fdca9bcc21a9271326f03b7430c1 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigImportRecreateTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigImportRecreateTest.php @@ -32,7 +32,7 @@ protected function setUp() { parent::setUp(); $this->installEntitySchema('node'); - $this->installConfig(['field', 'node']); + $this->installConfig(['system', 'field', 'node']); $this->copyConfig($this->container->get('config.storage'), $this->container->get('config.storage.sync')); diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigImportRenameValidationTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigImportRenameValidationTest.php index 84b5a2de1314c5596d65e636e803948d820bc624..f52b3956ab962d2a21dacf7fc0788aac8f39bae6 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigImportRenameValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigImportRenameValidationTest.php @@ -39,7 +39,7 @@ protected function setUp() { $this->installEntitySchema('user'); $this->installEntitySchema('node'); - $this->installConfig(['field']); + $this->installConfig(['system', 'field']); // Set up the ConfigImporter object for testing. $storage_comparer = new StorageComparer( diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterMissingContentTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterMissingContentTest.php index 59f9cb2ed728e88fda04f1699c2fb2e3b32debf2..db09e3f9f66d89d0aef48641599b68f4d5f6a60b 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterMissingContentTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterMissingContentTest.php @@ -33,7 +33,7 @@ protected function setUp() { $this->installSchema('system', 'sequences'); $this->installEntitySchema('entity_test'); $this->installEntitySchema('user'); - $this->installConfig(['config_test']); + $this->installConfig(['system', 'config_test']); // Installing config_test's default configuration pollutes the global // variable being used for recording hook invocations by this test already, // so it has to be cleared out manually. diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php index dc4e0ae12b9d64f95abc3764bb8c9b3402232209..6e8adf79a772960ec043de668d847edd1d652116 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php @@ -33,7 +33,7 @@ class ConfigImporterTest extends KernelTestBase { protected function setUp() { parent::setUp(); - $this->installConfig(['config_test']); + $this->installConfig(['system', 'config_test']); // Installing config_test's default configuration pollutes the global // variable being used for recording hook invocations by this test already, // so it has to be cleared out manually. diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigOverrideTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigOverrideTest.php index b2ddaa396ddd27c39e5115df37cdb622cfa5f09f..550e0d5b2b0f54406761fa629499c98d689ecdbc 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigOverrideTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigOverrideTest.php @@ -20,6 +20,7 @@ class ConfigOverrideTest extends KernelTestBase { protected function setUp() { parent::setUp(); + $this->installConfig(['system']); $this->copyConfig($this->container->get('config.storage'), $this->container->get('config.storage.sync')); } diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigSnapshotTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigSnapshotTest.php index 62eff04d4178585f06152e417233add1ef88eeb3..645638d83239ab51485db64af724c62320a5d037 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigSnapshotTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigSnapshotTest.php @@ -24,6 +24,7 @@ class ConfigSnapshotTest extends KernelTestBase { */ protected function setUp() { parent::setUp(); + $this->installConfig(['system']); // Update the config snapshot. This allows the parent::setUp() to write // configuration files. \Drupal::service('config.manager')->createSnapshot(\Drupal::service('config.storage'), \Drupal::service('config.storage.snapshot')); diff --git a/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityNullStorageTest.php b/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityNullStorageTest.php index 6da9bb3baad0fe7d1c9134b1acc19c7fd2d2c800..001d9ba3dee81b0b9f31650f6db133e3fb4f065c 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityNullStorageTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityNullStorageTest.php @@ -43,6 +43,7 @@ public function testEntityQuery() { * @see \Drupal\Core\Entity\Event\BundleConfigImportValidate */ public function testDeleteThroughImport() { + $this->installConfig(['system']); $contact_form = ContactForm::create(['id' => 'test']); $contact_form->save(); diff --git a/core/tests/fixtures/config_install/multilingual.tar.gz b/core/tests/fixtures/config_install/multilingual.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..d43aafa1e58ad82b62fa487bf9d28554e295e630 --- /dev/null +++ b/core/tests/fixtures/config_install/multilingual.tar.gz @@ -0,0 +1,52 @@ +",X}r8fSp:QѷX&,˷n(@(q3m0`* +E5 # >M-RN>u^;!?t$K/!ԑ +aAx2Cp~wr":hhɭȏ,7~z0)~ʯפ( BR 2\|t3,SBNɓBli!(}>tt|;K  5^1XA4 Ce5j ?C0yyzQ,K-Q+OaU~#1̦_cvJUc eXr5OX1VgOESԋ4 Zrn7JH~ni+C'-y#Das(BE?+J9@g@82Ԁ!hDSuIo&aMP(BX%Q9eœN]ϹvcK_l}vb` MwJ^Ku~6FT=+t}(kߛRC)tK,dBH4pup'BO8ëy-Ôo.s)\ z%(lHU~ǬIUoOژ:i >=| -t, py:+,Aֱ1(2@HҿLtyeCs@wKp|*k٫kfAN{M +f?>EŧK_1 {? ?hϳP aM@CJs(GY;5yao"ÇyStx OL!$g,bip< p_?$74 *f$Uu14Ry/.6mzlyc3m.wJyᇝ`MU 8<3|??*D<'uej(kxnUJצ5D{{qumz*6Iܗ?w%h^:h +w ?@p$^Yl +iQ5h:QJYm;Za d@!t |/50Yeb_ +4uovS{{?USv:mP1bgS&FSnK^r_ g?7$ 8ZUӈ @!/2:˫~L7hb67ف R#kP8Y /8~QGaq-mctW>cZ*{6@fl+Z:DEܩ; n5VAs>if YV&' F4.l "ϐ濨Iʲ`H?o7rʙWǏTлɎռbNáC YXw`D.vH{%._ Gs@VI?jh"PETfSﱆRyi|P[O'ж +w;zos&~?$wFL֐,&@9I߸AuYl]׺T3R/Uњt.f] s= G~]D?kc?]Z>#Ck$H:0bhRu {MQ%>M]<  +9I`:^[x!f3<iO%` B+m<|fU_xhw3=ޢg'sNrs2U͵=a-0]tngUONf =5P7!kD%7KL];meq|_-ЙJS-<|OJ|[?sa?$W+@t"TNꀪiuQ!Ue`ܷ}ieԁ` +>(eX՞qj< "'0^H /L$&E%6SrDh u0_JHi$]9ĝ2D* _e l,NL$#?Y}G#[bnqՇcyNQkEcol[Z XL84#E s )}S?2cƙjGK.vA3fcTP\0+C}?~Dԟ~?? K{ Ӂm#Y`5CfVn=6ꥶZp,/UAoXtLEP0F\bX8$ )4K +,Wzuqw[5bqz_8RidDt.H>Œu?bw\> R$hIU 0Sg5E#1y=A6+|?vdox.{w43n\NkˀGrb$sJ?EuӁIp}$X0W=<f^qM;kR.'s"<?| th 8sϚ qGL8CVaSKj馏4iI/9LRp(`P[N04i#&U"W!>ɔY׳ ?(8w" 5]PE ^/"ȊbE!j$£_Cq2Dwز2$\j*|6~P'ɀ~zGf,NOzL= }tXI>AXA/UJ{Sw +q֯% + +ʑo]}Ǖvm^f|ywqN]Hj?fW XXeV S5``]OUn\zzq0JA*ۙcڣjPUV7y!#2^^^?6Y5~r?6>96~.Xw +;4?eM"u,n5_Gor/ ߙ^9fXǶ~@D@9ziދ/FV,7`.~T8C}.i?3 Dm9! rG=ng9:3j/Edȏ?6Q ?A]٥ӡ'&WS&+ /e?F񟱽'x_׵r-GI'$>3:j*VQ!4LdUˑ r⾯p{t5_wC +zɯZ(ςM'ΠFv{AZݢ;1+W#O3(z>s$}:y!3{%QL` c30Cm_M]ᮃ.>Ya, bw)݀BeG JQU<@DPx?^?yύo1R?f9J3Q57[cO iG? +_&H~|'&cO(yLY.Upon.~"|&ۋ.yl HWx@e"F4)1?ZŇot#_[~m]PӧA ;IZB9Dv~z|&ew|ig^h1G +CP@H6Ǭ Vm{yLGn?!&"-J+BZ%IW~u[)þ9ڈk nTbMe-ߔJ4'2O_)Z^Gn w0V>sgtWT`E yD&9nwN(!:Kh.BmA A3fW>xhC^y+{濍E?=l_;a~$e&ACsu9#g4Mv +lw@#l.qvZ ;(x.9/CR?*zרGBZǻ+Ya޶ }yoEq +4}h8WK7I#Ü/PaV&nju3YxFY σ]ΕދtTfܕLaq{h ufn~橨_Ś$'Ŧ~1jl5ϋg7ܪP<2<+y! ߙ׍mJm۸m +FĉZ޵eύb>*xԇg?A$_8:_ϫd ˜ĨE"0yh*+^Qzi`T{Dԟ,=f3Z*KykI]Kt> :##rX*s@*U)ZLC|ۗ_5878N?}Y*m R{L8s PE W `y;fhV⢬#AƝz37m\v9i #[f9@[dJ`da4K +%N#j@Y 'm Qԩ͹5Yc1OBlY ~ O ^q-w?e"3@gϨ@dE]- +GXm>U-Z4lpY]Y3ʬX_56~oa$ N??Gvϲ b(ʐg ȼFYCU^w//޲ctF ]4gS;B]aӿ{'_kP$gp =$+k$uDp CH@ <iK X8*CԿUԊi-SU o:W,HzGv(۠}~?] R|j_PP ߔ/㊂9G.u!Eҟ|dw}tdYpC4[M2p=q,ar?##׊79)2}d#' 3;3B1+iO"҇BBv/vo,HGKعz0ȅEHO~9fo7 6s,!٧L)*]D7QO4-E鍖 {lC MU1jTVUjKSsߌM43\/+d8e:(XϮ}`ZiTezбhLk@  r 6naH[u^ρ <_|?@rg hy `5NChIpx\;`{D9md}<ܴ#{fBRW)/XBv9K0BQ[J( D&nǏNpp𢔏`)bKҘ\_w;KT}Xݶ~u>li~3A +A.9:3;1>3%AEJ3ӬP3(#Os}Sg6Ho!>'+"4>6d|dԍ88D/ڸ`h\bg̲4}.B. M_,x=%R4 a}ؽD+F]Yqe?)OvJOJӻ?6 +x$ +1e>rBkAҮ!TQȍ,RE"CW>:#LQ듿> ol"mڃiI^s^Gq%Ų#u?3rљ]??Hx +@F +x(C H4yMc #qgtubiRp;Ò^+3}Ӟ޻O{!L{ޚ$|D$q&Sg3I{?+&HBCAHU"C:-(d'"H +z&/W+eѼ zë{v՛b]QXt^M㞹bA$z b?=_& 'ϋo&H@'Z0u2'@&+·zRYC#VnڵqXV^E*TI\ JrMvl8c]?1>c' etWE$,CVƚhUG<5Ѡwl rcýϯDF]bAraqgn5ʁSs}WA΅!lJgiof +i@3Y 1&Jn0c!q$0ݱoyKhA +~ဧyRs;?aߐz>o%_=guh_W|૩_հ_UUh<7I&F4׬]7|f؋-ET6Q~Fӿ [??~ BOvvTQ]}3ب~x[$ +Eod#nwِ)'X %T-4r42;'k ?Ab"ij +hMEրI'I&v4 KWg,tI`CH^Ez|L>8(ꜬȞ㯘IA-LMmd&ќ3"m < +ȍYFu10Q֓^W'-~)gCU@_wA 8ƗGm3mDF󩣟٦滁kg:a}ȷV;*  #?LB$QK°x} H~L]ON?`:bD hr03ݝ}8"b{u:9ZOnߨ-Ӗ7rמ i֑\mkf/%FF?_ڟ +@ 'iYx~0b %=n +jQQ!_"u4}s;yd>6f͢[_xx3ͪ~,wH1/~,=?*(2- _ VH,xC#>c j6B>H+aqF] Z'-L?tGC3IdGdԻzE]>oWn=iׯGaΨ(~q}qgf|㤘ΈȌ'[\da&Y] yϗ}qv/ܿ}w +A8=j{o Y{OCtb2vQD|I ϱ{ Sg 6N՝Q ͍1.96_4DA"+DDSQBӉ{ކp^_;įqoJKkG>}P[x" +֏odyuO.~HygC=Jso:/ xIX5;%7>ObQ[q#{7vųӍ<֟^W$~ƪ5jSm\'[8} 63[7))! L]>}LI̶a¸RIjZ\+נZ6V~i_/O"ː4%D)& Dc(3-pS:C!k|)l6{fo%'04 Y^uhg02Hy Ӵ|eA9:~Ad/DW:r#[3shm})h|=Fvx1n;zhK0@M+a+EsIG @Wd +lNY#{xіr&sS ekOniWFX*>YrML߸W3dMr=gy7;+ +&%tܜ&˕ g~g&cAY7,n|-}s*\do"ДAGO 𳡮edWzO?@3iʪ +8(D(7\6՜Lwme7gF^(N1b"W :bDMb^kDiʬ~9 _(/+wǼjCrg> Gh)P#2?8evP4-%?˩LQ)p"0T9İ&߀oz0 ͵|PvO~S"? 9:}o熖Pv|ٛo~g7Z 9"뙤.Sx΃ŧ"i1P9GPU#JP[q] 8p{_\TKE{8D3G.{߆"IHJgo7-a_NJ1bPǫt-h߆@߽S{װZv7rY|5T*HsԷ< X qW +o ސ6KchϚ e(vcyC8>sM?}H3tKPV $d i h^5d9RGV|kkKMӅGC%^[`oA>O6F|k=f C ,t. p/rOC(#JMH0ɺJG =M;KήSs1*i=SO\o}3?谁w <+WXg:LKPYEIx 18MrD #j-{/b39|s4 RZqui:=~Ѥaop7G|^Ջ?i/鹖$5+'~ߊӽֱz7-KXO?+T簊o@^3NYʢ !RCӨSsGV/w K1 3m.ub~ٚmNc{)Vsn1bV2%=Z+,' @i ُCc,gp)oU<ŋ4dYT + sVi}M4OMB!T hF[=fʵkW3{` mL2<_9 hAcGC"P*k:{)΍5{)7˺6xtG˼P<9-3U owOP7H_Kg=zcg[|RJ `@l ƞ/C*JWoWc3H:T|C:+RĔௐwV)ڏ?O("ҢJ +hIC. !q.Y_S:0h.xWֽ,,r`)еE8,ↆ̾lsy?Bg4~Oө"H!MP ?CSy@xU #Qd=<)bLeneVy--.FK}Ywd6'L.ny{M^A/B$@q# HM(Ұv{fgqR_k,-a]pz:@oBRPW5 1XxD 3 eAFO; g~[Ϗsj5F;45̵]PCn[!xo9 O ߅(?528h4[ Ŀӗ ީw˰?i-\q2{Ԍ\ű7vBS/f 7{txy}_`wJDA((KY<"eXY8Z"Je;ןK)|6_h̒”Z?ف," &%Ǐ}_PQ1y`@t0*S~#~A[sy~-cmP/=^qŧ#3C}ws%Qp1u{$V`4?5,ƿMe]$+I*/PFNxP4?o9[^P. tlo2SiзkD//"ROx|/"j*$' 3x@ +F ^PRPOr₆q~qhM Sc4Ylt=Z)濇 Q?͋"pX7(h2-ckF(iV3x*S5omީF`M:BMm͠u@+jD:(ǿ|~8f_g(JL -YdSuFd+ſP`Xge^!m)oԉ=Zyݲ/ /McrC?@?slJ 2YC3XySf hRt=z=Pa3[*ᐭwE1p4NM:#]a/;/0ZBfN5R$뒬 GE:zcC \jh)nJ?ϕ'_S;xٷ3iPD -08 CaN_ ƍrQ1pl?ۑ2z U<iM[o2v]WꡩHQaAdQ<mjƝ% ģ C:8#n3'}e%Efsz{pQᅣO[y( נ4{moZWH\GPհr^R/2 j/hQ L|@Q?b"#O~]16V\q_N z0lFw_:chڻ>$teA @[T/r$t'˱VŊQϛ}nͳUz^NHkOuTZ2E\*~[rjD +g C8؞ + C,<4vƫu[!c]qߧuF\֏|.& ܛ_ֽЅֵ#4u_y)I(# e\nv+N#qݨB$]T_WAB^`(+z$7(7[4ݒ,jI6bq0)**KtRHSO 1qO`j|K{[rA`X&`6t0,K3(85,Q-Di\cXv?ˤ뿋Pa AuQd^PX rx('/sjXB +k0|( n۰s߹UGu.n"[6?M_x#l}c8"GfE(@eRS)ր1ip6Rta=>}]:cRΥ?ϥKgxE0 M%Ve&PRB=18r.t!*u~>v\5ÕM=>]#78z(`o^Ϥ_4+B^K48,*ͩT?t# h(N!_/=aHņ +cȞt[ (ߥW9>.B1eiˤ!IUP +d@A?{ߑ|_>εྌP+BaQPZT8z]kU=U"·e?x}f N]19 +YAe`rP} *?,#yoW-C-eӵh3(x?>I8Qs?}J,Ҫ85I4Ugi8^HKg廳EYZK)w;-e͜YП_:u1~.Iy%*D#4 gIPǀ7h p cM@"@3T]G ~ w6ڃg*3nܪ oox'uuC>] 9 (;~+I]J1! +^m YgGЬ"ZsLk `yDN4 8q{=jѴQ5kłˊz.V T[O7C 5 ]Iʸ]Q +C+~OZHPi *%BVt +٪g65Il͖0Ѕ4F"Tg۶86OT;ׅ~D+%c !ll8 8VZ4ϛR"ͣ|i#_ԣiY7aJw=yWV$|RNOYUb~7MǠQO?/Bвb>B)k9ԧu-NmkWQR- $dݫwe 8+QY;b=6w²g UVʓr@P__8r sRsLϟQAgNL$L +Zwڃ;Ty^C45y@;Qsq~,au ]䚓Y0kQ@f߇CRi(XxUHȌ)6G=uCCiL·^bNEAf M̌^隬t3P14Oi -NI +a#1ԡQg>*/z1\Zv$AZ>V[I? Reh|?2}8 HlmTs蘇-x~F5י +4:Hc|ˮ 8y~hUƝlWPE1[JgnR|xRQcWcچ#]?>#xM.B1]fJҺ +xce4o0M 4 5? h6O.{F쁑H gn5 f?KB?F'{S`gSPY@k2?P@bX41h>+s +>e>UiIdJ>,ՆRx?m܃H%mg@- LmEt!s1dcAKu$`ȋVLagSE(W'c8v+͏]!_|1(ő G_zũ\[4~'mfjoͰi}7oe7_MR5+${ȋ-+>d(^6j/_?SuL mq/_./pw_[=_( +D>3vefظAgR$ؿ|u_r5Eo2_^WӾ1\fHoljYWn%rA emٗjMcV1ǹE^hլ^e+G?!NeGY13Ezש_Ms|Iuág>h;*  B/!MRY\S#A%֒+\}6 ,F/} {W"s6&piiV|arڶ9O-?.CѬad@FG!X >W䅿P+ rz`6h=;zSF?5`n{Uf)|SPt/kzg72:ArM7rM3Ӳq]`L#x@|Iz4G  ?TH o{r);׹z~@?s<]o}я`8щ,O9/~,]y7 +? UFbȹEr!c0CQ<*VO:S֬yq×of&I1gqz>jAl} :+pQ g=bdD|kH:WE?Qw4lw7N ꌐX<ՒtN㍙': yțӞ(gK|lG9Sh)IS=͐VEo~WG垄Cte \Ϲ)׵m[|߮z&WY]uFޱ5Ň+H$BDQ)Jhm j:! |?G<׈G>}dy" +֏HxyuEO.zH{! "[R?'R /ՀgI ϓXa$5.F6Zk<{l Fr]nc54D#cm֜)n'aWzTl p2-eyT`E;Td^@GBsJh  C3t:KIuC^OJMMZ`w:rWd`K'u`vO!EE}؊Q@ W7?|&m?u1:fQ8>qJ<-p UX4%