From 5cb82824fc52396098ee490e8b05a5c28d626ab8 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Fri, 21 Jun 2024 14:05:55 -0700 Subject: [PATCH] Fixes #5999: Allow drush site:install to work with recipes. (#6026) * Allow drush site:install to work with recipes. * Code style * Add a test for installing from a recipe. * Touch up comments * Code style * Skip recipe tests on versions of Drupal that do not yet support recipes. * Rename "profile" arg to "recipeOrProfile". Add better error checking, e.g. Drupal versions and missing recipes. * Code style * Use test recipe on recipe-not-supported test. * Mark the recipe-not-supported test as skipped on sqlite. * Update src/Commands/core/SiteInstallCommands.php Co-authored-by: Moshe Weitzman * Search paths relative to cwd when looking for recipes. --------- Co-authored-by: Greg Anderson Co-authored-by: Moshe Weitzman --- src/Commands/core/SiteInstallCommands.php | 90 ++++++++++++++++--- .../config/user.role.administrator.yml | 8 ++ .../config/user.role.content_editor.yml | 18 ++++ tests/fixtures/recipes/test_recipe/recipe.yml | 42 +++++++++ tests/functional/SiteInstallTest.php | 73 ++++++++++++++- tests/unish/UnishTestCase.php | 5 +- 6 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/recipes/test_recipe/config/user.role.administrator.yml create mode 100644 tests/fixtures/recipes/test_recipe/config/user.role.content_editor.yml create mode 100644 tests/fixtures/recipes/test_recipe/recipe.yml diff --git a/src/Commands/core/SiteInstallCommands.php b/src/Commands/core/SiteInstallCommands.php index ef16cba0ae..8e0e122fbe 100644 --- a/src/Commands/core/SiteInstallCommands.php +++ b/src/Commands/core/SiteInstallCommands.php @@ -50,7 +50,7 @@ public function __construct( * Install Drupal along with modules/themes/configuration/profile. */ #[CLI\Command(name: self::INSTALL, aliases: ['si', 'sin', 'site-install'])] - #[CLI\Argument(name: 'profile', description: 'An install profile name. Defaults to standard unless an install profile is marked as a distribution. Use minimal for a bare minimum installation. Additional info for the install profile may also be provided with additional arguments. The key is in the form [form name].[parameter name]')] + #[CLI\Argument(name: 'recipeOrProfile', description: 'An install profile name, or a path to a directory containing a recipe. Relative paths are searched relative to both the Drupal root and the cwd. Defaults to standard unless an install profile is marked as a distribution. Use minimal for a bare minimum installation. Additional info for the install profile may also be provided with additional arguments. Use the format [form name].[parameter name]')] #[CLI\Option(name: 'db-url', description: 'A Drupal 10 style database URL. Required for initial install, not re-install. If omitted and required, Drush prompts for this item.')] #[CLI\Option(name: 'db-prefix', description: 'An optional table prefix to use for initial install.')] #[CLI\Option(name: 'db-su', description: 'Account to use when creating a new database. Must have Grant permission (mysql only). Optional.')] @@ -70,12 +70,13 @@ public function __construct( #[CLI\Usage(name: 'drush si --account-pass=mom', description: 'Re-install with specified uid1 password.')] #[CLI\Usage(name: 'drush si --existing-config', description: 'Install based on the yml files stored in the config export/import directory.')] #[CLI\Usage(name: 'drush si standard install_configure_form.enable_update_status_emails=NULL', description: 'Disable email notification during install and later. If your server has no mail transfer agent, this gets rid of an error during install.')] + #[CLI\Usage(name: 'drush si core/recipes/standard', description: 'Install from the core Standard recipe.')] #[CLI\Bootstrap(level: DrupalBootLevels::ROOT)] #[CLI\Kernel(name: Kernels::INSTALLER)] - public function install(array $profile, $options = ['db-url' => self::REQ, 'db-prefix' => self::REQ, 'db-su' => self::REQ, 'db-su-pw' => self::REQ, 'account-name' => 'admin', 'account-mail' => 'admin@example.com', 'site-mail' => 'admin@example.com', 'account-pass' => self::REQ, 'locale' => 'en', 'site-name' => 'Drush Site-Install', 'site-pass' => self::REQ, 'sites-subdir' => self::REQ, 'config-dir' => self::REQ, 'existing-config' => false]): void + public function install(array $recipeOrProfile, $options = ['db-url' => self::REQ, 'db-prefix' => self::REQ, 'db-su' => self::REQ, 'db-su-pw' => self::REQ, 'account-name' => 'admin', 'account-mail' => 'admin@example.com', 'site-mail' => 'admin@example.com', 'account-pass' => self::REQ, 'locale' => 'en', 'site-name' => 'Drush Site-Install', 'site-pass' => self::REQ, 'sites-subdir' => self::REQ, 'config-dir' => self::REQ, 'existing-config' => false]): void { - $additional = $profile; - $profile = array_shift($additional) ?: ''; + $additional = $recipeOrProfile; + $recipeOrProfile = array_shift($additional) ?: ''; $form_options = []; foreach ($additional as $arg) { list($key, $value) = explode('=', $arg, 2); @@ -91,8 +92,7 @@ public function install(array $profile, $options = ['db-url' => self::REQ, 'db-p } $this->serverGlobals($this->bootstrapManager->getUri()); - $profile = $this->determineProfile($profile, $options); - + list($recipe, $profile) = $this->determineRecipeOrProfile($recipeOrProfile, $options); $account_pass = $options['account-pass'] ?: StringUtils::generatePassword(); // Was giving error during validate() so its here for now. @@ -106,7 +106,7 @@ public function install(array $profile, $options = ['db-url' => self::REQ, 'db-p $settings = [ 'parameters' => [ - 'profile' => $profile, + 'profile' => $profile ?? '', 'langcode' => $options['locale'], 'existing_config' => $options['existing-config'], ], @@ -131,6 +131,13 @@ public function install(array $profile, $options = ['db-url' => self::REQ, 'db-p 'config_install_path' => $options['config-dir'], ]; + if ($recipe) { + if (version_compare(\Drupal::VERSION, '10.3.0') < 0) { + throw new \Exception('Recipes are only supported on Drupal 10.3.0 and later.'); + } + $settings['parameters']['recipe'] = $recipe; + } + $sql = SqlBase::create($options); if ($sql) { $db_spec = $sql->getDbSpec(); @@ -182,6 +189,66 @@ public function taskCallback($install_state): void $this->logger()->notice('Performed install task: {task}', ['task' => $install_state['active_task']]); } + /** + * Determine if the passed parameter is a recipe directory, or a profile name. + */ + protected function determineRecipeOrProfile($recipeOrProfile, $options): array + { + // Check for recipe relative to Drupal root + if ($this->validateRecipe($recipeOrProfile)) { + return [$recipeOrProfile, null]; + } + + // Check for recipe relative to cwd + if (!empty($recipeOrProfile) && !Path::isAbsolute($recipeOrProfile)) { + $relativeToCwdRecipePath = Path::join($this->getConfig()->cwd(), $recipeOrProfile); + if ($this->validateRecipe($relativeToCwdRecipePath)) { + return [$relativeToCwdRecipePath, null]; + } + } + + // If $recipeOrProfile is not a recipe, we'll check to see if it is + // a profile; however, first we will check and see if the parameter + // matches the required naming conventions for a profile. If it does + // not, we'll assume the user was trying to select a recipe that could + // not be found. + if (!empty($recipeOrProfile) && !$this->isValidProfileName($recipeOrProfile)) { + throw new \Exception(dt('Could not find a recipe.yml file for @recipe', ['@recipe' => $recipeOrProfile])); + } + + return [null, $this->determineProfile($recipeOrProfile, $options)]; + } + + /** + * Determine whether the provided profile name meets naming conventions. + * + * We do not check for reserved names; if a profile name _might_ be + * valid, we will pass it through to Drupal and let the system tell us + * if it is not allowed. + */ + protected function isValidProfileName(string $profile) + { + return preg_match('/^[a-z][a-z0-9_]*$/', $profile); + } + + /** + * Validates a user provided recipe. + * + * @param string $recipe + * The path to the recipe to validate. + * + * @return bool + * TRUE if the recipe exists, FALSE if not. + */ + protected function validateRecipe(string $recipe): bool + { + // It is impossible to validate a recipe fully at this point because that + // requires a container. + if (!is_dir($recipe) || !is_file($recipe . '/recipe.yml')) { + return false; + } + return true; + } protected function determineProfile($profile, $options): string|bool { @@ -280,11 +347,14 @@ public function validate(CommandData $commandData): void global $install_state; try { // Do some install booting to get basic services available. - $profile = array_shift($commandData->input()->getArgument('profile')) ?: ''; - $this->determineProfile($profile, $commandData->input()->getOptions()); + $recipeOrProfile = array_shift($commandData->input()->getArgument('recipeOrProfile')) ?: ''; + list($recipe, $profile) = $this->determineRecipeOrProfile($recipeOrProfile, $commandData->input()->getOptions()); require_once DRUSH_DRUPAL_CORE . '/includes/install.core.inc'; $install_state = ['interactive' => false] + install_state_defaults(); - $install_state['parameters']['profile'] = $profile; + $install_state['parameters']['profile'] = $profile ?? ''; + if ($recipe) { + $install_state['parameters']['recipe'] = $recipe; + } install_begin_request($this->autoloader, $install_state); // Get the installable drivers. diff --git a/tests/fixtures/recipes/test_recipe/config/user.role.administrator.yml b/tests/fixtures/recipes/test_recipe/config/user.role.administrator.yml new file mode 100644 index 0000000000..ee56127f3b --- /dev/null +++ b/tests/fixtures/recipes/test_recipe/config/user.role.administrator.yml @@ -0,0 +1,8 @@ +langcode: en +status: true +dependencies: { } +id: administrator +label: 'Site Administrator' +weight: 3 +is_admin: true +permissions: { } diff --git a/tests/fixtures/recipes/test_recipe/config/user.role.content_editor.yml b/tests/fixtures/recipes/test_recipe/config/user.role.content_editor.yml new file mode 100644 index 0000000000..bbbea1c4db --- /dev/null +++ b/tests/fixtures/recipes/test_recipe/config/user.role.content_editor.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: { } +id: content_editor +label: 'Site editor' +weight: 2 +is_admin: false +permissions: + - 'access administration pages' + - 'access content overview' + - 'access contextual links' + - 'access files overview' + - 'access toolbar' + - 'delete own files' + - 'revert all revisions' + - 'view all revisions' + - 'view own unpublished content' + - 'view the administration theme' diff --git a/tests/fixtures/recipes/test_recipe/recipe.yml b/tests/fixtures/recipes/test_recipe/recipe.yml new file mode 100644 index 0000000000..7b991156bd --- /dev/null +++ b/tests/fixtures/recipes/test_recipe/recipe.yml @@ -0,0 +1,42 @@ +name: 'Test Recipe' +description: 'A small subset of the Standard core recipe; contains just enough configuration to allow us to prove that the recipe was installed.' +type: 'Site' +install: + - image + - help + - history + - config + - contextual + - menu_link_content + - datetime + - menu_ui + - options + - toolbar + - field_ui + - views_ui + - shortcut +config: + import: + user: + - core.entity_view_mode.user.compact + - search.page.user_search + - views.view.user_admin_people + - views.view.who_s_new + - views.view.who_s_online + actions: + user.role.authenticated: + grantPermission: 'delete own files' + user.role.content_editor: + grantPermissionsForEachNodeType: + - 'create %bundle content' + - 'delete %bundle revisions' + - 'delete own %bundle content' + - 'edit own %bundle content' + user.role.anonymous: + # This recipe assumes all published content should be publicly accessible. + grantPermission: 'access content' + user.settings: + simple_config_update: + verify_mail: true + register: visitors_admin_approval + cancel_method: user_cancel_block diff --git a/tests/functional/SiteInstallTest.php b/tests/functional/SiteInstallTest.php index edf147c5e4..9811ee7ea3 100644 --- a/tests/functional/SiteInstallTest.php +++ b/tests/functional/SiteInstallTest.php @@ -4,8 +4,11 @@ namespace Unish; +use Drush\Commands\core\RoleCommands; use Drush\Commands\core\StatusCommands; use Drush\Commands\sql\SqlCommands; +use Drush\Commands\core\SiteInstallCommands; +use Unish\Utils\Fixtures; /** * @group base @@ -13,8 +16,10 @@ */ class SiteInstallTest extends CommandUnishTestCase { + use Fixtures; + /** - * Test functionality of site set. + * Test functionality of installing a site with a database prefix. */ public function testSiteInstallPrefix() { @@ -35,4 +40,70 @@ public function testSiteInstallPrefix() $this->assertStringContainsString('1', $output); } } + + /** + * Test functionality of attempting to install a profile that does not exist. + */ + public function testSiteInstallNoSuchProfile() + { + $this->drush(SiteInstallCommands::INSTALL, ['no_such_profile'], ['no-interaction' => null], null, null, self::EXIT_ERROR); + $error_output = $this->getErrorOutput(); + $this->assertStringContainsString('The profile no_such_profile does not exist.', $error_output); + } + + /** + * Test functionality of attempting to install a recipe that does not exist. + */ + public function testSiteInstallNoSuchRecipe() + { + $this->drush(SiteInstallCommands::INSTALL, ['core/recipes/no-such-recipe'], ['no-interaction' => null], null, null, self::EXIT_ERROR); + $error_output = $this->getErrorOutput(); + $this->assertStringContainsString('Could not find a recipe.yml file for core/recipes/no-such-recipe', $error_output); + } + + /** + * Test functionality of attempting to install a recipe on a version of Drupal that does not support them. + */ + public function testSiteInstallRecipesNotSupported() + { + if ($this->isDrupalGreaterThanOrEqualTo('10.3.0')) { + $this->markTestSkipped('We can only test the recipes requirement check on versions prior to Drupal 10.3.0.'); + } + + if ($this->dbDriver() === 'sqlite') { + $this->markTestSkipped('This test runs afoul of profile-selection code that does not work right with SQLite, since we have not set up the db-url for this test.'); + } + + $recipeDir = $this->fixturesDir() . '/recipes/test_recipe'; + $this->drush(SiteInstallCommands::INSTALL, [$recipeDir], ['no-interaction' => null], null, null, self::EXIT_ERROR); + $error_output = $this->getErrorOutput(); + $this->assertStringContainsString('Recipes are only supported on Drupal 10.3.0 and later.', $error_output); + } + + /** + * Test functionality of installing a site with a recipe. + */ + public function testSiteInstallRecipe() + { + if (!$this->isDrupalGreaterThanOrEqualTo('10.3.0')) { + $this->markTestSkipped('Recipes require Drupal 10.3.0 or later.'); + } + + // Install Drupal with our test recipe. + $recipeDir = $this->fixturesDir() . '/recipes/test_recipe'; + $this->installDrupal('dev', true, ['recipeOrProfile' => $recipeDir]); + + // Run 'core-status' and insure that we can bootstrap Drupal. + $this->drush(StatusCommands::STATUS, [], ['fields' => 'bootstrap']); + $output = $this->getOutput(); + $this->assertStringContainsString('Successful', $output); + + // Fetch the Content Editor role and see if its label is 'Site Editor'. + // The label of Content Editor in the Standard profile & recipe is + // 'Content Editor', so if our expectation is satisfied, we know that + // we must have installed from our recipe, and not from anywhere else. + $this->drush(RoleCommands::LIST, [], ['format' => 'json']); + $roles = $this->getOutputFromJSON(); + $this->assertEquals('Site editor', $roles['content_editor']['label']); + } } diff --git a/tests/unish/UnishTestCase.php b/tests/unish/UnishTestCase.php index 9c31b90b36..f36a7a498b 100644 --- a/tests/unish/UnishTestCase.php +++ b/tests/unish/UnishTestCase.php @@ -678,13 +678,16 @@ protected function installSut($uri = self::INTEGRATION_TEST_ENV, $optionsFromTes 'db-url' => $this->dbUrl($uri), 'sites-subdir' => $uri, 'yes' => true, + 'recipeOrProfile' => 'testing', // or path to recipe directory // quiet suppresses error reporting as well. // 'quiet' => true, ]; if ($level = $this->logLevel()) { $options[$level] = true; } - $process = $this->processManager()->siteProcess($sutAlias, [self::getDrush(), SiteInstallCommands::INSTALL, 'testing', 'install_configure_form.enable_update_status_emails=NULL'], $options); + $recipeOrProfile = $options['recipeOrProfile']; + unset($options['recipeOrProfile']); + $process = $this->processManager()->siteProcess($sutAlias, [self::getDrush(), SiteInstallCommands::INSTALL, $recipeOrProfile, 'install_configure_form.enable_update_status_emails=NULL'], $options); // Set long timeout because Xdebug slows everything. $process->setTimeout(0); $this->process = $process;