From d3fdc93bc57e4349bebf4c6127cabdbf1eb63904 Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
Date: Sat, 30 Mar 2024 14:59:24 +0000
Subject: [PATCH] Issue #3432602 by alexpott, phenaproxima: Allow a site to be
 installed from configuration with no profile

---
 core/includes/install.core.inc                | 56 ++++++++++++-------
 core/includes/install.inc                     | 17 +++++-
 .../Core/Installer/Form/SelectProfileForm.php | 35 ++++++++----
 .../Core/Test/FunctionalTestSetupTrait.php    | 20 ++++---
 .../InstallerExistingConfigNoProfileTest.php  | 19 +++++++
 .../InstallerExistingConfigTestBase.php       | 52 +++++++++++------
 6 files changed, 138 insertions(+), 61 deletions(-)
 create mode 100644 core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigNoProfileTest.php

diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc
index ebb746cb5c11..0672b37bbf8f 100644
--- a/core/includes/install.core.inc
+++ b/core/includes/install.core.inc
@@ -303,8 +303,16 @@ function install_begin_request($class_loader, &$install_state) {
   }
 
   // Validate certain core settings that are used throughout the installation.
-  if (!empty($install_state['parameters']['profile'])) {
+  if (array_key_exists('profile', $install_state['parameters'])) {
     $install_state['parameters']['profile'] = preg_replace('/[^a-zA-Z_0-9]/', '', $install_state['parameters']['profile']);
+    // If the site is not using an install profile then profile will be set to
+    // an empty string in the query parameters. Convert this to a FALSE value so
+    // that \Drupal\Core\Installer\InstallerKernel::getInstallProfile() returns
+    // the same value as \Drupal\Core\DrupalKernel::getInstallProfile() when the
+    // site does not have an install profile.
+    if ($install_state['parameters']['profile'] === '') {
+      $install_state['parameters']['profile'] = FALSE;
+    }
   }
   if (!empty($install_state['parameters']['langcode'])) {
     $install_state['parameters']['langcode'] = preg_replace('/[^a-zA-Z_0-9\-]/', '', $install_state['parameters']['langcode']);
@@ -451,7 +459,8 @@ function install_begin_request($class_loader, &$install_state) {
     $module_list->setPathname($name, $profile->getPathname());
   }
 
-  if ($profile = _install_select_profile($install_state)) {
+  $profile = _install_select_profile($install_state);
+  if ($profile !== NULL) {
     $install_state['parameters']['profile'] = $profile;
     install_load_profile($install_state);
     if (isset($install_state['profile_info']['distribution']['install']['theme'])) {
@@ -835,15 +844,17 @@ function install_tasks($install_state) {
     // Load the profile install file, because it is not always loaded when
     // hook_install_tasks() is invoked (e.g. batch processing).
     $profile = $install_state['parameters']['profile'];
-    $profile_install_file = $install_state['profiles'][$profile]->getPath() . '/' . $profile . '.install';
-    if (file_exists($profile_install_file)) {
-      include_once \Drupal::root() . '/' . $profile_install_file;
-    }
-    $function = $install_state['parameters']['profile'] . '_install_tasks';
-    if (function_exists($function)) {
-      $result = $function($install_state);
-      if (is_array($result)) {
-        $tasks += $result;
+    if ($profile !== FALSE) {
+      $profile_install_file = $install_state['profiles'][$profile]->getPath() . '/' . $profile . '.install';
+      if (file_exists($profile_install_file)) {
+        include_once \Drupal::root() . '/' . $profile_install_file;
+      }
+      $function = $install_state['parameters']['profile'] . '_install_tasks';
+      if (function_exists($function)) {
+        $result = $function($install_state);
+        if (is_array($result)) {
+          $tasks += $result;
+        }
       }
     }
   }
@@ -1202,7 +1213,7 @@ function install_database_errors($database, $settings_file) {
  *   thrown if a profile cannot be chosen automatically.
  */
 function install_select_profile(&$install_state) {
-  if (empty($install_state['parameters']['profile'])) {
+  if (!array_key_exists('profile', $install_state['parameters'])) {
     // If there are no profiles at all, installation cannot proceed.
     if (empty($install_state['profiles'])) {
       throw new NoProfilesException(\Drupal::service('string_translation'));
@@ -1245,9 +1256,9 @@ function install_select_profile(&$install_state) {
  *   The current installer state, containing a 'profiles' key, which is an
  *   associative array of profiles with the machine-readable names as keys.
  *
- * @return string|null
+ * @return string|null|false
  *   The machine-readable name of the selected profile or NULL if no profile was
- *   selected.
+ *   selected or FALSE if the site has no profile.
  *
  *  @see install_select_profile()
  */
@@ -1257,9 +1268,9 @@ function _install_select_profile(&$install_state) {
     return key($install_state['profiles']);
   }
   // If a valid profile has already been selected, return the selection.
-  if (!empty($install_state['parameters']['profile'])) {
+  if (array_key_exists('profile', $install_state['parameters'])) {
     $profile = $install_state['parameters']['profile'];
-    if (isset($install_state['profiles'][$profile])) {
+    if ($profile === FALSE || isset($install_state['profiles'][$profile])) {
       return $profile;
     }
   }
@@ -1497,8 +1508,10 @@ function _install_get_version_info($version) {
  */
 function install_load_profile(&$install_state) {
   $profile = $install_state['parameters']['profile'];
-  $install_state['profiles'][$profile]->load();
-  $install_state['profile_info'] = install_profile_info($profile, $install_state['parameters']['langcode'] ?? 'en');
+  if ($profile !== FALSE) {
+    $install_state['profiles'][$profile]->load();
+    $install_state['profile_info'] = install_profile_info($profile, $install_state['parameters']['langcode'] ?? 'en');
+  }
 
   $sync_directory = Settings::get('config_sync_directory');
   if (!empty($install_state['parameters']['existing_config']) && !empty($sync_directory)) {
@@ -2047,10 +2060,13 @@ function install_check_translations($langcode, $server_pattern) {
  * Checks installation requirements and reports any errors.
  */
 function install_check_requirements($install_state) {
+  $requirements = [];
   $profile = $install_state['parameters']['profile'];
 
-  // Check the profile requirements.
-  $requirements = drupal_check_profile($profile);
+  if ($profile !== FALSE) {
+    // Check the profile requirements.
+    $requirements = drupal_check_profile($profile);
+  }
 
   if ($install_state['settings_verified']) {
     return $requirements;
diff --git a/core/includes/install.inc b/core/includes/install.inc
index 8eb0630b7b9c..3474c7c3cafa 100644
--- a/core/includes/install.inc
+++ b/core/includes/install.inc
@@ -147,9 +147,15 @@ function drupal_install_profile_distribution_version() {
  *
  * @return array
  *   The list of modules to install.
+ *
+ * @todo https://www.drupal.org/i/3005959 Rework this method as it is not only
+ *   about profiles.
  */
 function drupal_verify_profile($install_state) {
   $profile = $install_state['parameters']['profile'];
+  if ($profile === FALSE) {
+    return [];
+  }
   $info = $install_state['profile_info'];
 
   // Get the list of available modules for the selected installation profile.
@@ -233,9 +239,14 @@ function drupal_install_system($install_state) {
 
   // Store the installation profile in configuration to populate the
   // 'install_profile' container parameter.
-  \Drupal::configFactory()->getEditable('core.extension')
-    ->set('profile', $install_state['parameters']['profile'])
-    ->save();
+  $config = \Drupal::configFactory()->getEditable('core.extension');
+  if ($install_state['parameters']['profile'] === FALSE) {
+    $config->clear('profile');
+  }
+  else {
+    $config->set('profile', $install_state['parameters']['profile']);
+  }
+  $config->save();
 
   $connection = Database::getConnection();
   $provider = $connection->getProvider();
diff --git a/core/lib/Drupal/Core/Installer/Form/SelectProfileForm.php b/core/lib/Drupal/Core/Installer/Form/SelectProfileForm.php
index 98578d10f816..5c2becd1bff6 100644
--- a/core/lib/Drupal/Core/Installer/Form/SelectProfileForm.php
+++ b/core/lib/Drupal/Core/Installer/Form/SelectProfileForm.php
@@ -88,18 +88,29 @@ public function buildForm(array $form, FormStateInterface $form_state, $install_
       $sync = new FileStorage($config_sync_directory);
       $extensions = $sync->read('core.extension');
       $site = $sync->read('system.site');
-      if (isset($site['name']) && isset($extensions['profile']) && in_array($extensions['profile'], array_keys($names), TRUE)) {
-        // Ensure the profile can be installed from configuration. Install
-        // profile's which implement hook_INSTALL() are not supported.
-        // @todo https://www.drupal.org/project/drupal/issues/2982052 Remove
-        //   this restriction.
-        $root = \Drupal::root();
-        include_once $root . '/core/includes/install.inc';
-        $file = $root . '/' . $install_state['profiles'][$extensions['profile']]->getPath() . "/{$extensions['profile']}.install";
-        if (is_file($file)) {
-          require_once $file;
+      if (isset($site['name'])) {
+        $install_from_config = FALSE;
+        if (isset($extensions['profile']) && array_key_exists($extensions['profile'], $names)) {
+          // Ensure the profile can be installed from configuration. Install
+          // profile's which implement hook_INSTALL() are not supported.
+          // @todo https://www.drupal.org/project/drupal/issues/2982052 Remove
+          //   this restriction.
+          $root = \Drupal::root();
+          include_once $root . '/core/includes/install.inc';
+          $file = $root . '/' . $install_state['profiles'][$extensions['profile']]->getPath() . "/{$extensions['profile']}.install";
+          if (is_file($file)) {
+            require_once $file;
+          }
+          if (!function_exists($extensions['profile'] . '_install')) {
+            $install_from_config = TRUE;
+          }
         }
-        if (!function_exists($extensions['profile'] . '_install')) {
+        elseif (empty($extensions['profile'])) {
+          // Allow sites without a profile to be installed.
+          $install_from_config = TRUE;
+        }
+
+        if ($install_from_config) {
           $form['profile']['#options'][static::CONFIG_INSTALL_PROFILE_KEY] = $this->t('Use existing configuration');
           $form['profile'][static::CONFIG_INSTALL_PROFILE_KEY]['#description'] = [
             'description' => [
@@ -139,7 +150,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
     $profile = $form_state->getValue('profile');
     if ($profile === static::CONFIG_INSTALL_PROFILE_KEY) {
       $sync = new FileStorage(Settings::get('config_sync_directory'));
-      $profile = $sync->read('core.extension')['profile'];
+      $profile = $sync->read('core.extension')['profile'] ?? FALSE;
       $install_state['parameters']['existing_config'] = TRUE;
     }
     $install_state['parameters']['profile'] = $profile;
diff --git a/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php b/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php
index 363eba0bf0ef..b7c6d8861231 100644
--- a/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php
+++ b/core/lib/Drupal/Core/Test/FunctionalTestSetupTrait.php
@@ -425,16 +425,18 @@ protected function installDefaultThemeFromClassProperty(ContainerInterface $cont
     // not already specified.
     $profile = $container->getParameter('install_profile');
 
-    $default_sync_path = $container->get('extension.list.profile')->getPath($profile) . '/config/sync';
-    $profile_config_storage = new FileStorage($default_sync_path, StorageInterface::DEFAULT_COLLECTION);
-    if (!isset($this->defaultTheme) && $profile_config_storage->exists('system.theme')) {
-      $this->defaultTheme = $profile_config_storage->read('system.theme')['default'];
-    }
+    if (!empty($profile)) {
+      $default_sync_path = $container->get('extension.list.profile')->getPath($profile) . '/config/sync';
+      $profile_config_storage = new FileStorage($default_sync_path, StorageInterface::DEFAULT_COLLECTION);
+      if (!isset($this->defaultTheme) && $profile_config_storage->exists('system.theme')) {
+        $this->defaultTheme = $profile_config_storage->read('system.theme')['default'];
+      }
 
-    $default_install_path = $container->get('extension.list.profile')->getPath($profile) . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY;
-    $profile_config_storage = new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION);
-    if (!isset($this->defaultTheme) && $profile_config_storage->exists('system.theme')) {
-      $this->defaultTheme = $profile_config_storage->read('system.theme')['default'];
+      $default_install_path = $container->get('extension.list.profile')->getPath($profile) . '/' . InstallStorage::CONFIG_INSTALL_DIRECTORY;
+      $profile_config_storage = new FileStorage($default_install_path, StorageInterface::DEFAULT_COLLECTION);
+      if (!isset($this->defaultTheme) && $profile_config_storage->exists('system.theme')) {
+        $this->defaultTheme = $profile_config_storage->read('system.theme')['default'];
+      }
     }
 
     // Require a default theme to be specified at this point.
diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigNoProfileTest.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigNoProfileTest.php
new file mode 100644
index 000000000000..e10cb2910fec
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigNoProfileTest.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalTests\Installer;
+
+/**
+ * Verifies that installing from existing configuration without a profile works.
+ *
+ * @group Installer
+ */
+class InstallerExistingConfigNoProfileTest extends InstallerExistingConfigTest {
+
+  /**
+   * Tests the install from config without a profile.
+   */
+  protected $profile = FALSE;
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php
index fc98122f37b3..44347c668723 100644
--- a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php
+++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php
@@ -16,6 +16,8 @@ abstract class InstallerExistingConfigTestBase extends InstallerTestBase {
 
   /**
    * This is set by the profile in the core.extension extracted.
+   *
+   * If set to FALSE, then the install will proceed without an install profile.
    */
   protected $profile = NULL;
 
@@ -36,16 +38,30 @@ protected function prepareEnvironment() {
       $this->profile = $core_extension['profile'];
     }
 
-    // Create a profile for testing. We set core_version_requirement to '*' for
-    // the test so that it does not need to be updated between major versions.
-    $info = [
-      'type' => 'profile',
-      'core_version_requirement' => '*',
-      'name' => 'Configuration installation test profile (' . $this->profile . ')',
-    ];
+    if ($this->profile !== FALSE) {
+      // Create a profile for testing. We set core_version_requirement to '*' for
+      // the test so that it does not need to be updated between major versions.
+      $info = [
+        'type' => 'profile',
+        'core_version_requirement' => '*',
+        'name' => 'Configuration installation test profile (' . $this->profile . ')',
+      ];
+
+      // File API functions are not available yet.
+      $path = $this->siteDirectory . '/profiles/' . $this->profile;
+
+      // Put the sync directory inside the profile.
+      $config_sync_directory = $path . '/config/sync';
+
+      mkdir($path, 0777, TRUE);
+      file_put_contents("$path/{$this->profile}.info.yml", Yaml::encode($info));
+    }
+    else {
+      // If we have no profile we must use an existing sync directory.
+      $this->existingSyncDirectory = TRUE;
+      $config_sync_directory = $this->siteDirectory . '/config/sync';
+    }
 
-    // File API functions are not available yet.
-    $path = $this->siteDirectory . '/profiles/' . $this->profile;
     if ($this->existingSyncDirectory) {
       $config_sync_directory = $this->siteDirectory . '/config/sync';
       $this->settings['settings']['config_sync_directory'] = (object) [
@@ -53,13 +69,6 @@ protected function prepareEnvironment() {
         'required' => TRUE,
       ];
     }
-    else {
-      // Put the sync directory inside the profile.
-      $config_sync_directory = $path . '/config/sync';
-    }
-
-    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.
     mkdir($config_sync_directory, 0777, TRUE);
@@ -81,8 +90,17 @@ protected function prepareEnvironment() {
       if ($module !== 'core') {
         $core_extension['module'][$module] = 0;
         $core_extension['module'] = module_config_sort($core_extension['module']);
-        file_put_contents($config_sync_directory . '/core.extension.yml', Yaml::encode($core_extension));
       }
+      if ($this->profile === FALSE && array_key_exists('profile', $core_extension)) {
+        // Remove the profile.
+        unset($core_extension['module'][$core_extension['profile']]);
+        unset($core_extension['profile']);
+
+        // Set a default theme to the first theme that will be installed as this
+        // can not be retrieved from the profile.
+        $this->defaultTheme = array_key_first($core_extension['theme']);
+      }
+      file_put_contents($config_sync_directory . '/core.extension.yml', Yaml::encode($core_extension));
     }
   }
 
-- 
GitLab