Skip to content

Commit

Permalink
Fixes #5999: Allow drush site:install to work with recipes. (#6026)
Browse files Browse the repository at this point in the history
* 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 <weitzman@tejasa.com>

* Search paths relative to cwd when looking for recipes.

---------

Co-authored-by: Greg Anderson <greg.anderson@greenknowe.org>
Co-authored-by: Moshe Weitzman <weitzman@tejasa.com>
  • Loading branch information
3 people authored Jun 21, 2024
1 parent 5b1d3d8 commit 5cb8282
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 12 deletions.
90 changes: 80 additions & 10 deletions src/Commands/core/SiteInstallCommands.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <info>standard</info> unless an install profile is marked as a distribution. Use <info>minimal</info> for a bare minimum installation. Additional info for the install profile may also be provided with additional arguments. The key is in the form <info>[form name].[parameter name]</info>')]
#[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 <info>standard</info> unless an install profile is marked as a distribution. Use <info>minimal</info> for a bare minimum installation. Additional info for the install profile may also be provided with additional arguments. Use the format <info>[form name].[parameter name]</info>')]
#[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.')]
Expand All @@ -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);
Expand All @@ -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.
Expand All @@ -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'],
],
Expand All @@ -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();
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
langcode: en
status: true
dependencies: { }
id: administrator
label: 'Site Administrator'
weight: 3
is_admin: true
permissions: { }
Original file line number Diff line number Diff line change
@@ -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'
42 changes: 42 additions & 0 deletions tests/fixtures/recipes/test_recipe/recipe.yml
Original file line number Diff line number Diff line change
@@ -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
73 changes: 72 additions & 1 deletion tests/functional/SiteInstallTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@

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
* @group slow
*/
class SiteInstallTest extends CommandUnishTestCase
{
use Fixtures;

/**
* Test functionality of site set.
* Test functionality of installing a site with a database prefix.
*/
public function testSiteInstallPrefix()
{
Expand All @@ -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']);
}
}
5 changes: 4 additions & 1 deletion tests/unish/UnishTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 5cb8282

Please sign in to comment.