diff --git a/core/config/install/core.extension.yml b/core/config/install/core.extension.yml index 659446cc64c88900a59666f480bf3a475678b5dc..ca917793c9c77d913164de0d102aed25800f6992 100644 --- a/core/config/install/core.extension.yml +++ b/core/config/install/core.extension.yml @@ -1,3 +1,3 @@ module: {} theme: {} -profile: '' +profile: null diff --git a/core/config/schema/core.extension.schema.yml b/core/config/schema/core.extension.schema.yml index 087e2e3b9497f75ca86794bbd34796bb8b302350..19f52db5a3f41a1c5cbeb8fbc48561f6d26b1a5c 100644 --- a/core/config/schema/core.extension.schema.yml +++ b/core/config/schema/core.extension.schema.yml @@ -16,4 +16,10 @@ core.extension: label: 'Weight' profile: type: string + # Before Drupal is installed the profile is NULL. This allows all install + # profiles to be discovered by the installer. + nullable: true + # After Drupal is installed, if the install profile is uninstalled the key + # will be removed. + requiredKey: false label: 'Install profile' diff --git a/core/core.services.yml b/core/core.services.yml index b97667e9e618039c44b62cbf8cb26179d2e2fdef..df762b1a4c6d3ea42afe455bc0c58f1d0c00a17e 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -676,6 +676,12 @@ services: - { name: module_install.uninstall_validator } arguments: ['@string_translation', '@extension.list.module', '@database'] lazy: true + install_profile_uninstall_validator: + class: Drupal\Core\Extension\InstallProfileUninstallValidator + tags: + - { name: module_install.uninstall_validator } + arguments: ['@string_translation', '@extension.list.module', '@extension.list.theme', '%install_profile%', '%app.root%', '%site.path%'] + lazy: true theme_handler: class: Drupal\Core\Extension\ThemeHandler arguments: ['%app.root%', '@config.factory', '@extension.list.theme'] diff --git a/core/includes/install.inc b/core/includes/install.inc index 9c8a859768a20ab9fc72c3e3d83e7d3cf61837da..62aa02ef5af8df571b84a2c5769e8d165761b3c4 100644 --- a/core/includes/install.inc +++ b/core/includes/install.inc @@ -109,8 +109,7 @@ function drupal_install_profile_distribution_name() { } } // At all other times, we load the profile via standard methods. - else { - $profile = \Drupal::installProfile(); + elseif ($profile = \Drupal::installProfile()) { $info = \Drupal::service('extension.list.profile')->getExtensionInfo($profile); } return $info['distribution']['name'] ?? 'Drupal'; diff --git a/core/lib/Drupal.php b/core/lib/Drupal.php index 99db2aec84534981e3c78ce8244a69f2d66a69ea..c42f5043d3481cef31bf4d0a354b9458e714b21c 100644 --- a/core/lib/Drupal.php +++ b/core/lib/Drupal.php @@ -223,8 +223,10 @@ public static function root() { /** * Gets the active install profile. * - * @return string|null - * The name of the active install profile. + * @return string|false|null + * The name of the active install profile. FALSE indicates that the site is + * not using an install profile. NULL indicates that the site has not yet + * been installed. */ public static function installProfile() { return static::getContainer()->getParameter('install_profile'); diff --git a/core/lib/Drupal/Core/Asset/LibrariesDirectoryFileFinder.php b/core/lib/Drupal/Core/Asset/LibrariesDirectoryFileFinder.php index 9102efc475c663bd2ef279bb2f1fa0fa10dab5b0..adc00f0e61420e535ac9e087eb1e62cc0494229f 100644 --- a/core/lib/Drupal/Core/Asset/LibrariesDirectoryFileFinder.php +++ b/core/lib/Drupal/Core/Asset/LibrariesDirectoryFileFinder.php @@ -33,7 +33,7 @@ class LibrariesDirectoryFileFinder { /** * The install profile. * - * @var string + * @var string|false|null */ protected $installProfile; diff --git a/core/lib/Drupal/Core/Config/ConfigImporter.php b/core/lib/Drupal/Core/Config/ConfigImporter.php index 8331c1bc7c0400b523286b1fe30b40e3edfb0ab2..9ecd177e6f4b3687fd8c25adaf37de37a54bf3a6 100644 --- a/core/lib/Drupal/Core/Config/ConfigImporter.php +++ b/core/lib/Drupal/Core/Config/ConfigImporter.php @@ -455,12 +455,18 @@ protected function createExtensionChangelist() { $this->extensionChangelist['module']['install'] = array_keys($install_required + $install_non_required); - // 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']; + // If we're installing the install profile ensure it comes last in the + // list of modules to be installed. This will occur when installing a site + // from configuration. + if (isset($new_extensions['profile'])) { + $install_profile_key = array_search($new_extensions['profile'], $this->extensionChangelist['module']['install'], TRUE); + // If the profile is not in the list of modules to be installed this will + // generate a validation error. See + // \Drupal\Core\EventSubscriber\ConfigImportSubscriber::validateModules(). + if ($install_profile_key !== FALSE) { + unset($this->extensionChangelist['module']['install'][$install_profile_key]); + $this->extensionChangelist['module']['install'][] = $new_extensions['profile']; + } } // Get a list of themes with dependency weights as values. diff --git a/core/lib/Drupal/Core/Config/ConfigInstaller.php b/core/lib/Drupal/Core/Config/ConfigInstaller.php index a7f4ce4dd4d1c43d847632ff7385bac75b1ce7fe..ec94fb331c7535f95dde201ce73a330d9498c399 100644 --- a/core/lib/Drupal/Core/Config/ConfigInstaller.php +++ b/core/lib/Drupal/Core/Config/ConfigInstaller.php @@ -63,7 +63,7 @@ class ConfigInstaller implements ConfigInstallerInterface { /** * The name of the currently active installation profile. * - * @var string + * @var string|false|null */ protected $installProfile; diff --git a/core/lib/Drupal/Core/Config/ExtensionInstallStorage.php b/core/lib/Drupal/Core/Config/ExtensionInstallStorage.php index 1006013f5f2dd7fb0c44b9bd93372913bd0dd861..e48b255ed3f5ac331013aa2cdd629b6f22262d0b 100644 --- a/core/lib/Drupal/Core/Config/ExtensionInstallStorage.php +++ b/core/lib/Drupal/Core/Config/ExtensionInstallStorage.php @@ -31,7 +31,7 @@ class ExtensionInstallStorage extends InstallStorage { * * In the early installer this value can be NULL. * - * @var string|null + * @var string|false|null */ protected $installProfile; diff --git a/core/lib/Drupal/Core/DrupalKernel.php b/core/lib/Drupal/Core/DrupalKernel.php index 53af40828788e1e841478bffdb2212413208e032..229b388f71908f871a9756cf73127aea2280ba1c 100644 --- a/core/lib/Drupal/Core/DrupalKernel.php +++ b/core/lib/Drupal/Core/DrupalKernel.php @@ -1712,13 +1712,15 @@ protected function collectServiceIdMapping() { /** * Gets the active install profile. * - * @return string|null - * The name of the any active install profile or distribution. + * @return string|false|null + * The name of the active install profile or distribution, FALSE if there is + * no install profile or NULL if Drupal is being installed. */ protected function getInstallProfile() { $config = $this->getConfigStorage()->read('core.extension'); - - // Normalize an empty string to a NULL value. + if (is_array($config) && !array_key_exists('profile', $config)) { + return FALSE; + } return $config['profile'] ?? NULL; } diff --git a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php index 29d78a24dadda35a8314d4b6c7698ee22430d065..d2259643b44ed4c1dfd0ab90b0e86ee4a1d1bd1c 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php @@ -112,24 +112,30 @@ protected function validateModules(ConfigImporter $config_importer) { // Get the install profile from the site's configuration. $current_core_extension = $config_importer->getStorageComparer()->getTargetStorage()->read('core.extension'); $install_profile = $current_core_extension['profile'] ?? NULL; + $new_install_profile = $core_extension['profile'] ?? NULL; // Ensure the profile is not changing. - if ($install_profile !== $core_extension['profile']) { + if ($install_profile !== $new_install_profile) { if (InstallerKernel::installationAttempted()) { $config_importer->logError($this->t('The selected installation profile %install_profile does not match the profile stored in configuration %config_profile.', [ '%install_profile' => $install_profile, - '%config_profile' => $core_extension['profile'], + '%config_profile' => $new_install_profile, ])); // If this error has occurred the other checks are irrelevant. return; } - else { + elseif ($new_install_profile) { $config_importer->logError($this->t('Cannot change the install profile from %profile to %new_profile once Drupal is installed.', [ '%profile' => $install_profile, - '%new_profile' => $core_extension['profile'], + '%new_profile' => $new_install_profile, ])); } } + elseif ($new_install_profile && !isset($core_extension['module'][$new_install_profile])) { + $config_importer->logError($this->t('The install profile %profile is not in the list of installed modules.', [ + '%profile' => $new_install_profile, + ])); + } // Get a list of modules with dependency weights as values. $module_data = $this->moduleExtensionList->getList(); @@ -180,12 +186,6 @@ protected function validateModules(ConfigImporter $config_importer) { } } } - - // Ensure that the install profile is not being uninstalled. - if (in_array($install_profile, $uninstalls, TRUE)) { - $profile_name = $module_data[$install_profile]->info['name']; - $config_importer->logError($this->t('Unable to uninstall the %profile profile since it is the install profile.', ['%profile' => $profile_name])); - } } /** diff --git a/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php b/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php index b0cfa7d13e5a529287c7a785e9e7952bdf755736..307afa9aac12263a7c5b163ab5ecad0764329497 100644 --- a/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php +++ b/core/lib/Drupal/Core/Extension/ExtensionDiscovery.php @@ -234,7 +234,18 @@ public function setProfileDirectoriesFromSettings() { if (!\Drupal::hasContainer() || !\Drupal::getContainer()->hasParameter('install_profile')) { return $this; } - if ($profile = \Drupal::installProfile()) { + + $profile = \Drupal::installProfile(); + // If $profile is FALSE then we need to add a fake directory as a profile + // directory in order to filter out profile provided modules. This ensures + // that, after uninstalling a profile, a site cannot install modules + // contained in an install profile. During installation $profile will be + // NULL, so we need to discover all modules and profiles. + if ($profile === FALSE) { + // cspell:ignore CNKDSIUSYFUISEFCB + $this->profileDirectories[] = '_does_not_exist_profile_CNKDSIUSYFUISEFCB'; + } + elseif ($profile) { $this->profileDirectories[] = \Drupal::service('extension.list.profile')->getPath($profile); } return $this; diff --git a/core/lib/Drupal/Core/Extension/ExtensionList.php b/core/lib/Drupal/Core/Extension/ExtensionList.php index 6f4c4b7c3ac3b27b359bb952db955d7ba6b6923f..26dce53016e55e9480273c4e37d47e11919b69ca 100644 --- a/core/lib/Drupal/Core/Extension/ExtensionList.php +++ b/core/lib/Drupal/Core/Extension/ExtensionList.php @@ -112,7 +112,7 @@ abstract class ExtensionList { /** * The install profile used by the site. * - * @var string + * @var string|false|null */ protected $installProfile; diff --git a/core/lib/Drupal/Core/Extension/InstallProfileUninstallValidator.php b/core/lib/Drupal/Core/Extension/InstallProfileUninstallValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..97eb71f67e1255447a3126a7187c4db72ea6798e --- /dev/null +++ b/core/lib/Drupal/Core/Extension/InstallProfileUninstallValidator.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Extension; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; + +/** + * Ensures install profile can only be uninstalled if the modules are available. + */ +class InstallProfileUninstallValidator implements ModuleUninstallValidatorInterface { + + use StringTranslationTrait; + + /** + * Extension discovery that scans all folders except profiles. + * + * @var \Drupal\Core\Extension\ExtensionDiscovery + */ + protected ExtensionDiscovery $noProfileExtensionDiscovery; + + public function __construct( + TranslationInterface $string_translation, + protected ModuleExtensionList $moduleExtensionList, + protected ThemeExtensionList $themeExtensionList, + protected string|false|null $installProfile, + protected string $root, + protected string $sitePath + ) { + $this->setStringTranslation($string_translation); + } + + /** + * {@inheritdoc} + */ + public function validate($module): array { + $reasons = []; + + // When there are modules installed that only exist in the install profile's + // directory an install profile can not be uninstalled. + if ($module === $this->installProfile) { + $profile_name = $this->moduleExtensionList->get($module)->info['name']; + + $profile_only_modules = array_diff_key($this->moduleExtensionList->getAllInstalledInfo(), $this->getExtensionDiscovery()->scan('module')); + // Remove the install profile as we're uninstalling it. + unset($profile_only_modules[$module]); + if (!empty($profile_only_modules)) { + $reasons[] = $this->t("The install profile '@profile_name' is providing the following module(s): @profile_modules", + ['@profile_name' => $profile_name, '@profile_modules' => implode(', ', array_keys($profile_only_modules))]); + } + + $profile_only_themes = array_diff_key($this->themeExtensionList->getAllInstalledInfo(), $this->getExtensionDiscovery()->scan('theme')); + if (!empty($profile_only_themes)) { + $reasons[] = $this->t("The install profile '@profile_name' is providing the following theme(s): @profile_themes", + ['@profile_name' => $profile_name, '@profile_themes' => implode(', ', array_keys($profile_only_themes))]); + } + } + elseif (!empty($this->installProfile)) { + $extension = $this->moduleExtensionList->get($module); + // Ensure that the install profile does not depend on the module being + // uninstalled. + if (isset($extension->required_by[$this->installProfile])) { + $profile_name = $this->moduleExtensionList->get($this->installProfile)->info['name']; + $reasons[] = $this->t("The '@profile_name' install profile requires '@module_name'", + ['@profile_name' => $profile_name, '@module_name' => $extension->info['name']]); + } + } + + return $reasons; + } + + /** + * Gets an extension discovery object that ignores the install profile. + * + * @return \Drupal\Core\Extension\ExtensionDiscovery + * An extension discovery object to look for extensions not in a profile + * directory. + */ + protected function getExtensionDiscovery(): ExtensionDiscovery { + if (!isset($this->noProfileExtensionDiscovery)) { + // cspell:ignore CNKDSIUSYFUISEFCB + $this->noProfileExtensionDiscovery = new ExtensionDiscovery($this->root, TRUE, ['_does_not_exist_profile_CNKDSIUSYFUISEFCB'], $this->sitePath); + } + return $this->noProfileExtensionDiscovery; + } + +} diff --git a/core/lib/Drupal/Core/Extension/ModuleExtensionList.php b/core/lib/Drupal/Core/Extension/ModuleExtensionList.php index 6337cd08940b16fb66c986b787585da0db1e8ba6..1176b566796e89d2f890adea768f773adbf3c54b 100644 --- a/core/lib/Drupal/Core/Extension/ModuleExtensionList.php +++ b/core/lib/Drupal/Core/Extension/ModuleExtensionList.php @@ -180,8 +180,6 @@ protected function doList() { $active_profile->info['hidden'] = TRUE; } - // The installation profile is required. - $active_profile->info['required'] = TRUE; // Add a default distribution name if the profile did not provide one. // @see install_profile_info() // @see drupal_install_profile_distribution_name() diff --git a/core/lib/Drupal/Core/Extension/ModuleInstaller.php b/core/lib/Drupal/Core/Extension/ModuleInstaller.php index 289ecffe41fbaf3d3884851ee6050985775e1005..7323af67e9188f370a5d909fc608a960451bf065 100644 --- a/core/lib/Drupal/Core/Extension/ModuleInstaller.php +++ b/core/lib/Drupal/Core/Extension/ModuleInstaller.php @@ -500,7 +500,15 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) { // Remove the module's entry from the config. Don't check schema when // uninstalling a module since we are only clearing a key. - \Drupal::configFactory()->getEditable('core.extension')->clear("module.$module")->save(TRUE); + $core_extension = \Drupal::configFactory()->getEditable('core.extension'); + $core_extension->clear("module.$module"); + // If the install profile is being uninstalled then remove the site's + // profile key to indicate that the site no longer has an installation + // profile. + if ($core_extension->get('profile') === $module) { + $core_extension->clear('profile'); + } + $core_extension->save(TRUE); // Update the module handler to remove the module. // The current ModuleHandler instance is obsolete with the kernel rebuild diff --git a/core/lib/Drupal/Core/ProxyClass/Extension/InstallProfileUninstallValidator.php b/core/lib/Drupal/Core/ProxyClass/Extension/InstallProfileUninstallValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..9c7213dea6a20e7ce2fa84c236b79097d24fddd6 --- /dev/null +++ b/core/lib/Drupal/Core/ProxyClass/Extension/InstallProfileUninstallValidator.php @@ -0,0 +1,88 @@ +<?php +// phpcs:ignoreFile + +/** + * This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\Core\Extension\InstallProfileUninstallValidator' "core/lib/Drupal/Core". + */ + +namespace Drupal\Core\ProxyClass\Extension { + + /** + * Provides a proxy class for \Drupal\Core\Extension\InstallProfileUninstallValidator. + * + * @see \Drupal\Component\ProxyBuilder + */ + class InstallProfileUninstallValidator implements \Drupal\Core\Extension\ModuleUninstallValidatorInterface + { + + use \Drupal\Core\DependencyInjection\DependencySerializationTrait; + + /** + * The id of the original proxied service. + * + * @var string + */ + protected $drupalProxyOriginalServiceId; + + /** + * The real proxied service, after it was lazy loaded. + * + * @var \Drupal\Core\Extension\InstallProfileUninstallValidator + */ + protected $service; + + /** + * The service container. + * + * @var \Symfony\Component\DependencyInjection\ContainerInterface + */ + protected $container; + + /** + * Constructs a ProxyClass Drupal proxy object. + * + * @param \Symfony\Component\DependencyInjection\ContainerInterface $container + * The container. + * @param string $drupal_proxy_original_service_id + * The service ID of the original service. + */ + public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id) + { + $this->container = $container; + $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id; + } + + /** + * Lazy loads the real service from the container. + * + * @return object + * Returns the constructed real service. + */ + protected function lazyLoadItself() + { + if (!isset($this->service)) { + $this->service = $this->container->get($this->drupalProxyOriginalServiceId); + } + + return $this->service; + } + + /** + * {@inheritdoc} + */ + public function validate($module) + { + return $this->lazyLoadItself()->validate($module); + } + + /** + * {@inheritdoc} + */ + public function setStringTranslation(\Drupal\Core\StringTranslation\TranslationInterface $translation) + { + return $this->lazyLoadItself()->setStringTranslation($translation); + } + + } + +} diff --git a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php index 581434e0e273a60a9274a661bc54640e07b25301..8b574c83807dfe9aadbac9e21b1d06dac6b6e93f 100644 --- a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php +++ b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php @@ -110,7 +110,7 @@ public function testInstallUninstall() { $all_modules = \Drupal::service('extension.list.module')->getList(); $database_module = \Drupal::service('database')->getProvider(); - $expected_modules = ['path_alias', 'system', 'user', 'testing', $database_module]; + $expected_modules = ['path_alias', 'system', 'user', $database_module]; // Ensure that only core required modules and the install profile can not be uninstalled. $validation_reasons = \Drupal::service('module_installer')->validateUninstall(array_keys($all_modules)); @@ -118,8 +118,8 @@ public function testInstallUninstall() { $this->assertEqualsCanonicalizing($expected_modules, $validation_modules); $modules_to_uninstall = array_filter($all_modules, function ($module) { - // Filter required and not enabled modules. - if (!empty($module->info['required']) || $module->status == FALSE) { + // Filter profiles, and required and not enabled modules. + if (!empty($module->info['required']) || $module->status == FALSE || $module->getType() === 'profile') { return FALSE; } return TRUE; diff --git a/core/modules/config/tests/src/Functional/ConfigImportInstallProfileTest.php b/core/modules/config/tests/src/Functional/ConfigImportInstallProfileTest.php index c3fac44ff29f1722afb7dbca772cae485752f9f6..67b3f93e520780c2dbbb442ad3f600089f6881f3 100644 --- a/core/modules/config/tests/src/Functional/ConfigImportInstallProfileTest.php +++ b/core/modules/config/tests/src/Functional/ConfigImportInstallProfileTest.php @@ -51,7 +51,10 @@ protected function setUp(): void { } /** - * Tests config importer cannot uninstall install profiles. + * Tests config importer can uninstall install profiles. + * + * Install profiles can be uninstalled when none of the modules or themes + * they contain are installed. * * @see \Drupal\Core\EventSubscriber\ConfigImportSubscriber */ @@ -67,10 +70,11 @@ public function testInstallProfileValidation() { $this->drupalGet('admin/config/development/configuration'); $this->submitForm([], 'Import all'); $this->assertSession()->pageTextContains('The configuration cannot be imported because it failed validation for the following reasons:'); - $this->assertSession()->pageTextContains('Unable to uninstall the Testing config import profile since it is the install profile.'); + $this->assertSession()->pageTextContains("The install profile 'Testing config import' is providing the following module(s): testing_config_import_module"); // Uninstall dependencies of testing_config_import. unset($core['module']['syslog']); + unset($core['module']['testing_config_import_module']); unset($core['theme']['stark']); $core['module']['testing_config_import'] = 0; $core['theme']['test_theme_theme'] = 0; @@ -84,8 +88,26 @@ public function testInstallProfileValidation() { $this->assertSession()->pageTextContains('The configuration was imported successfully.'); $this->rebuildContainer(); $this->assertFalse(\Drupal::moduleHandler()->moduleExists('syslog'), 'The syslog module has been uninstalled.'); + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('testing_config_import_module'), 'The testing_config_import_module module has been uninstalled.'); $this->assertFalse(\Drupal::service('theme_handler')->themeExists('stark'), 'The stark theme has been uninstalled.'); $this->assertTrue(\Drupal::service('theme_handler')->themeExists('test_theme_theme'), 'The test_theme_theme theme has been installed.'); + + // Uninstall testing_config_import profile without removing the profile key. + unset($core['module']['testing_config_import']); + $sync->write('core.extension', $core); + $this->drupalGet('admin/config/development/configuration'); + $this->submitForm([], 'Import all'); + $this->assertSession()->pageTextContains('The configuration cannot be imported because it failed validation for the following reasons:'); + $this->assertSession()->pageTextContains('The install profile testing_config_import is not in the list of installed modules.'); + + // Uninstall testing_config_import profile properly. + unset($core['profile']); + $sync->write('core.extension', $core); + $this->drupalGet('admin/config/development/configuration'); + $this->submitForm([], 'Import all'); + $this->assertSession()->pageTextContains('The configuration was imported successfully.'); + $this->rebuildContainer(); + $this->assertFalse(\Drupal::moduleHandler()->moduleExists('testing_config_import'), 'The testing_config_import profile has been uninstalled.'); } } diff --git a/core/modules/system/src/Form/ModulesUninstallConfirmForm.php b/core/modules/system/src/Form/ModulesUninstallConfirmForm.php index 0af5e4dade92d19f9b4e7aa0ffe1847542201802..7014e9144ed601f6cb698d3b384255737d4be773 100644 --- a/core/modules/system/src/Form/ModulesUninstallConfirmForm.php +++ b/core/modules/system/src/Form/ModulesUninstallConfirmForm.php @@ -76,13 +76,16 @@ class ModulesUninstallConfirmForm extends ConfirmFormBase { * The entity type manager. * @param \Drupal\Core\Extension\ModuleExtensionList $extension_list_module * The module extension list. + * @param string|false|null $installProfile + * The install profile. */ - public function __construct(ModuleInstallerInterface $module_installer, KeyValueStoreExpirableInterface $key_value_expirable, ConfigManagerInterface $config_manager, EntityTypeManagerInterface $entity_type_manager, ModuleExtensionList $extension_list_module) { + public function __construct(ModuleInstallerInterface $module_installer, KeyValueStoreExpirableInterface $key_value_expirable, ConfigManagerInterface $config_manager, EntityTypeManagerInterface $entity_type_manager, ModuleExtensionList $extension_list_module, protected string|false|null $installProfile = NULL) { $this->moduleInstaller = $module_installer; $this->keyValueExpirable = $key_value_expirable; $this->configManager = $config_manager; $this->entityTypeManager = $entity_type_manager; $this->moduleExtensionList = $extension_list_module; + $this->installProfile ??= \Drupal::installProfile(); } /** @@ -94,7 +97,8 @@ public static function create(ContainerInterface $container) { $container->get('keyvalue.expirable')->get('modules_uninstall'), $container->get('config.manager'), $container->get('entity_type.manager'), - $container->get('extension.list.module') + $container->get('extension.list.module'), + $container->getParameter('install_profile') ); } @@ -156,6 +160,10 @@ public function buildForm(array $form, FormStateInterface $form_state) { }, $this->modules), ]; + if (!empty($this->installProfile) && in_array($this->installProfile, $this->modules, TRUE)) { + $form['profile']['#markup'] = '<p>' . $this->t('Once uninstalled, the %install_profile profile cannot be reinstalled.', ['%install_profile' => $data[$this->installProfile]->info['name']]) . '</p>'; + } + // List the dependent entities. $this->addDependencyListsToForm($form, 'module', $this->modules, $this->configManager, $this->entityTypeManager); diff --git a/core/modules/system/system.install b/core/modules/system/system.install index 09ea1af714f8f2d54d4cfabca8b037589ef66233..26cc2577742486e23ed865c643d9adf1861143c3 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -85,7 +85,7 @@ function system_requirements($phase) { // Display the currently active installation profile, if the site // is not running the default installation profile. $profile = \Drupal::installProfile(); - if ($profile != 'standard') { + if ($profile != 'standard' && !empty($profile)) { $info = $module_extension_list->getExtensionInfo($profile); $requirements['install_profile'] = [ 'title' => t('Installation profile'), diff --git a/core/modules/system/tests/src/Kernel/Extension/ModuleHandlerTest.php b/core/modules/system/tests/src/Kernel/Extension/ModuleHandlerTest.php index faf58cb8f49a4ecabd74e22110e6265e18e6563b..a2d1535da6616a60a63280348166b18578d8608e 100644 --- a/core/modules/system/tests/src/Kernel/Extension/ModuleHandlerTest.php +++ b/core/modules/system/tests/src/Kernel/Extension/ModuleHandlerTest.php @@ -201,9 +201,25 @@ public function testUninstallProfileDependency() { $this->assertNotContains($profile, $uninstalled_modules, 'The installation profile is not in the list of uninstalled modules.'); // Try uninstalling the required module. - $this->expectException(ModuleUninstallValidatorException::class); - $this->expectExceptionMessage('The following reasons prevent the modules from being uninstalled: The Testing install profile dependencies module is required'); - $this->moduleInstaller()->uninstall([$dependency]); + try { + $this->moduleInstaller()->uninstall([$dependency]); + $this->fail('Expected ModuleUninstallValidatorException not thrown'); + } + catch (ModuleUninstallValidatorException $e) { + $this->assertEquals("The following reasons prevent the modules from being uninstalled: The 'Testing install profile dependencies' install profile requires 'Database Logging'", $e->getMessage()); + } + + // Try uninstalling the install profile. + $this->assertSame('testing_install_profile_dependencies', $this->container->getParameter('install_profile')); + $result = $this->moduleInstaller()->uninstall([$profile]); + $this->assertTrue($result, 'ModuleInstaller::uninstall() returns TRUE.'); + $this->assertFalse($this->moduleHandler()->moduleExists($profile)); + $this->assertFalse($this->container->getParameter('install_profile')); + + // Try uninstalling the required module again. + $result = $this->moduleInstaller()->uninstall([$dependency]); + $this->assertTrue($result, 'ModuleInstaller::uninstall() returns TRUE.'); + $this->assertFalse($this->moduleHandler()->moduleExists($dependency)); } /** @@ -235,7 +251,7 @@ public function testProfileAllDependencies() { // Try uninstalling the dependencies. $this->expectException(ModuleUninstallValidatorException::class); - $this->expectExceptionMessage('The following reasons prevent the modules from being uninstalled: The Testing install profile all dependencies module is required'); + $this->expectExceptionMessage("The following reasons prevent the modules from being uninstalled: The 'Testing install profile all dependencies' install profile requires 'Database Logging'; The 'Testing install profile all dependencies' install profile requires 'Ban'"); $this->moduleInstaller()->uninstall($dependencies); } diff --git a/core/profiles/testing_config_import/testing_config_import.info.yml b/core/profiles/testing_config_import/testing_config_import.info.yml index 29f5e5239e10f0586a50e1b92ab90681e5da18d6..b1ae768473d8bd87259b3ceac262fee731c30e2b 100644 --- a/core/profiles/testing_config_import/testing_config_import.info.yml +++ b/core/profiles/testing_config_import/testing_config_import.info.yml @@ -5,5 +5,6 @@ version: VERSION hidden: true install: - syslog + - testing_config_import_module themes: - stark diff --git a/core/profiles/testing_config_import/testing_config_import_module/testing_config_import_module.info.yml b/core/profiles/testing_config_import/testing_config_import_module/testing_config_import_module.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..2370c42591aa161034fa2857065b2a14fac5af85 --- /dev/null +++ b/core/profiles/testing_config_import/testing_config_import_module/testing_config_import_module.info.yml @@ -0,0 +1,5 @@ +name: 'Testing config import module' +type: module +description: 'Tests install profiles in the config importer.' +version: VERSION +package: Testing diff --git a/core/profiles/testing_config_import/testing_config_import_theme/testing_config_import_theme.info.yml b/core/profiles/testing_config_import/testing_config_import_theme/testing_config_import_theme.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..7e5157d78a835fbeaac3017de6332b4ced7afad7 --- /dev/null +++ b/core/profiles/testing_config_import/testing_config_import_theme/testing_config_import_theme.info.yml @@ -0,0 +1,13 @@ +name: 'Testing config import theme' +type: theme +base theme: stable9 +description: 'Theme for testing profile uninstall' +version: VERSION +regions: + sidebar_first: 'Left sidebar' + sidebar_second: 'Right sidebar' + content: Content + header: Header + footer: Footer + highlighted: Highlighted + help: Help diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallProfileDependenciesTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallProfileDependenciesTest.php index f5c75f52b8dc63f5f749756260e4e454032bdc31..7087cecf8fc74ff92825a16d7e27355bd73fb770 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallProfileDependenciesTest.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallProfileDependenciesTest.php @@ -46,7 +46,7 @@ public function testUninstallingModules() { $this->fail('Uninstalled dblog module.'); } catch (ModuleUninstallValidatorException $e) { - $this->assertStringContainsString('The Testing install profile dependencies module is required', $e->getMessage()); + $this->assertStringContainsString("The 'Testing install profile dependencies' install profile requires 'Database Logging'", $e->getMessage()); } } diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallProfileUninstallTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallProfileUninstallTest.php new file mode 100644 index 0000000000000000000000000000000000000000..57720e6b20b243180e62f93c9979f3eea82242dc --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallProfileUninstallTest.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\FunctionalTests\Installer; + +use Drupal\Tests\BrowserTestBase; + +/** + * Tests that an install profile can be uninstalled. + * + * @group Extension + */ +class InstallProfileUninstallTest extends BrowserTestBase { + + /** + * The profile to install as a basis for testing. + * + * This profile is used because it contains a submodule. + * + * @var string + */ + protected $profile = 'testing_config_import'; + + /** + * {@inheritdoc} + */ + protected static $modules = ['config']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests a user can uninstall install profiles. + */ + public function testUninstallInstallProfile(): void { + $this->drupalLogin($this->drupalCreateUser(admin: TRUE)); + + // Ensure that the installation profile is present on the status report. + $this->drupalGet('admin/reports/status'); + $this->assertSession()->pageTextContains("Installation profile"); + $this->assertSession()->pageTextContains("Testing config import"); + + // Test uninstalling a module provided by the install profile. + $this->drupalGet('admin/modules/uninstall'); + $this->assertSession()->pageTextContains("The install profile 'Testing config import' is providing the following module(s): testing_config_import_module"); + $this->assertSession()->fieldDisabled('uninstall[testing_config_import]'); + $this->assertSession()->fieldEnabled('uninstall[testing_config_import_module]')->check(); + $this->getSession()->getPage()->pressButton('Uninstall'); + $this->getSession()->getPage()->pressButton('Uninstall'); + $this->assertSession()->pageTextContains('The selected modules have been uninstalled.'); + $this->assertSession()->fieldNotExists('uninstall[testing_config_import_module]'); + $this->assertSession()->pageTextNotContains("The install profile 'Testing config import' is providing the following module(s): testing_config_import_module"); + + // Test that we can reinstall the module from the profile. + $this->drupalGet('admin/modules'); + $this->assertSession()->pageTextContains('Testing config import module'); + $this->assertSession()->fieldEnabled('modules[testing_config_import_module][enable]')->check(); + $this->getSession()->getPage()->pressButton('Install'); + $this->assertSession()->pageTextContains('Module Testing config import module has been installed.'); + + // Install a theme provided by the module. + $this->drupalGet('admin/appearance'); + $this->clickLink("Install Testing config import theme theme"); + $this->assertSession()->pageTextContains("The Testing config import theme theme has been installed."); + + // Test that uninstalling the module and then the profile works. + $this->drupalGet('admin/modules/uninstall'); + $this->assertSession()->pageTextContains("The install profile 'Testing config import' is providing the following module(s): testing_config_import_module"); + $this->assertSession()->pageTextContains("The install profile 'Testing config import' is providing the following theme(s): testing_config_import_theme"); + $this->assertSession()->fieldEnabled('uninstall[testing_config_import_module]')->check(); + $this->getSession()->getPage()->pressButton('Uninstall'); + $this->getSession()->getPage()->pressButton('Uninstall'); + $this->assertSession()->pageTextContains('The selected modules have been uninstalled.'); + $this->assertSession()->fieldNotExists('uninstall[testing_config_import_module]'); + $this->drupalGet('admin/appearance'); + $this->clickLink("Uninstall Testing config import theme theme"); + $this->assertSession()->pageTextContains("The Testing config import theme theme has been uninstalled."); + $this->drupalGet('admin/modules/uninstall'); + $this->assertSession()->pageTextNotContains("The install profile 'Testing config import' is providing the following module(s): testing_config_import_module"); + $this->assertSession()->pageTextNotContains("The install profile 'Testing config import' is providing the following theme(s): testing_config_import_theme"); + $this->assertSession()->fieldEnabled('uninstall[testing_config_import]')->check(); + $this->getSession()->getPage()->pressButton('Uninstall'); + $this->assertSession()->pageTextContains('Once uninstalled, the Testing config import profile cannot be reinstalled.'); + $this->getSession()->getPage()->pressButton('Uninstall'); + $this->assertSession()->pageTextContains('The selected modules have been uninstalled.'); + $this->assertSession()->fieldNotExists('uninstall[testing_config_import]'); + + // Test that the module contained in the profile is no longer available to + // install. + $this->drupalGet('admin/modules'); + $this->assertSession()->pageTextNotContains('Testing config import module'); + $this->assertSession()->fieldNotExists('modules[testing_config_import_module][enable]'); + + // Ensure that the installation profile is not present on the status report. + $this->drupalGet('admin/reports/status'); + $this->assertSession()->pageTextNotContains("Installation profile"); + $this->assertSession()->pageTextNotContains("Testing config import"); + } + +} diff --git a/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php index 5ecb85d0afb688f5258b400638437794e1302d94..abe08cb9a2e22b5e25be38555a8b1caea0978d8e 100644 --- a/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php +++ b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php @@ -217,7 +217,10 @@ public static function providerMappingInterpretation(): \Generator { 'theme', 'profile', ], - ['_core'], + [ + '_core', + 'profile', + ], [], ]; diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php index e690f6d016e72ace585ea4e8364906778c04fda2..091c8a8f6031d134579cc379e77b6e278db3a9e8 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigImporterTest.php @@ -752,6 +752,10 @@ public function testInstallProfile() { * @see \Drupal\Core\EventSubscriber\ConfigImportSubscriber */ public function testInstallProfileMisMatch() { + // Install profiles can not be changed. They can only be uninstalled. We + // need to set an install profile prior to testing because KernelTestBase + // tests do not use one. + $this->setInstallProfile('minimal'); $sync = $this->container->get('config.storage.sync'); $extensions = $sync->read('core.extension'); @@ -765,14 +769,10 @@ public function testInstallProfileMisMatch() { $this->fail('ConfigImporterException not thrown; an invalid import was not stopped due to missing dependencies.'); } catch (ConfigImporterException $e) { - $expected = static::FAIL_MESSAGE . PHP_EOL . 'Cannot change the install profile from <em class="placeholder"></em> to <em class="placeholder">this_will_not_work</em> once Drupal is installed.'; + $expected = static::FAIL_MESSAGE . PHP_EOL . 'Cannot change the install profile from <em class="placeholder">minimal</em> to <em class="placeholder">this_will_not_work</em> once Drupal is installed.'; $this->assertEquals($expected, $e->getMessage(), 'There were errors validating the config synchronization.'); $error_log = $config_importer->getErrors(); - // Install profiles can not be changed. Note that KernelTestBase currently - // does not use an install profile. This situation should be impossible - // to get in but site's can removed the install profile setting from - // settings.php so the test is valid. - $this->assertEquals(['Cannot change the install profile from to this_will_not_work once Drupal is installed.'], $error_log); + $this->assertEquals(['Cannot change the install profile from minimal to this_will_not_work once Drupal is installed.'], $error_log); } } diff --git a/core/tests/Drupal/KernelTests/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php index 2e449e8c7d1d863bda2a8abe541fd0db9575e3a1..7e3fc2d995f4fca23a42e301e5e04703afc5bf40 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBase.php +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php @@ -414,7 +414,6 @@ protected function bootKernel() { $this->container->get('config.storage')->write('core.extension', [ 'module' => array_fill_keys($modules, 0), 'theme' => [], - 'profile' => '', ]); $settings = Settings::getAll(); diff --git a/core/tests/Drupal/Tests/Core/Extension/DefaultConfigTest.php b/core/tests/Drupal/Tests/Core/Extension/DefaultConfigTest.php index 44a01e2f6c7b4eae270a305f2b5ca9594a1ac266..5818422fdcddaa1a5a2a5a2c45fc7b0365fa8719 100644 --- a/core/tests/Drupal/Tests/Core/Extension/DefaultConfigTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/DefaultConfigTest.php @@ -29,7 +29,7 @@ public function testConfigIsEmpty() { $expected = [ 'module' => [], 'theme' => [], - 'profile' => '', + 'profile' => NULL, ]; $this->assertEquals($expected, $config); }