Verified Commit 74da82a3 authored by Dave Long's avatar Dave Long
Browse files

Issue #3439923 by alexpott, longwave, thejimbirch, Wim Leers, phenaproxima,...

Issue #3439923 by alexpott, longwave, thejimbirch, Wim Leers, phenaproxima, immaculatexavier, nedjo, bircher, deviantintegral, franz, narendraR, omkar.podey, srishtiiee, Rajab Natshah, millnut, mondrake, amateescu, larowlan, sonfd, tasc, vasike: Add recipes api as experimental API to core
parent 4a4a78b5
Loading
Loading
Loading
Loading
Loading
+19 −0
Original line number Diff line number Diff line
@@ -67,6 +67,17 @@ parameters:
services:
  _defaults:
    autoconfigure: true
  plugin.manager.config_action:
    class: Drupal\Core\Config\Action\ConfigActionManager
    parent: default_plugin_manager
    arguments: ['@config.manager', '@config.storage', '@config.typed', '@config.factory']
  Drupal\Core\DefaultContent\Importer:
    autowire: true
  Drupal\Core\DefaultContent\AdminAccountSwitcher:
    arguments:
      $isSuperUserAccessEnabled: '%security.enable_super_user%'
    autowire: true
    public: false
  # Simple cache contexts, directly derived from the request context.
  cache_context.ip:
    class: Drupal\Core\Cache\Context\IpCacheContext
@@ -385,6 +396,14 @@ services:
    public: false
    tags:
      - { name: backend_overridable }
  config.storage.checkpoint:
    class: Drupal\Core\Config\Checkpoint\CheckpointStorage
    arguments: [ '@config.storage', '@config.checkpoints', '@keyvalue' ]
  Drupal\Core\Config\Checkpoint\CheckpointStorageInterface: '@config.storage.checkpoint'
  config.checkpoints:
    class: Drupal\Core\Config\Checkpoint\LinearHistory
    arguments: [ '@state', '@datetime.time' ]
  Drupal\Core\Config\Checkpoint\CheckpointListInterface: '@config.checkpoints'
  config.import_transformer:
    class: Drupal\Core\Config\ImportStorageTransformer
    arguments: ['@event_dispatcher', '@database', '@lock', '@lock.persistent']
+91 −0
Original line number Diff line number Diff line
@@ -26,6 +26,8 @@
use Drupal\Core\Installer\InstallerKernel;
use Drupal\Core\Language\Language;
use Drupal\Core\Language\LanguageManager;
use Drupal\Core\Recipe\Recipe;
use Drupal\Core\Recipe\RecipeRunner;
use Drupal\Core\Site\Settings;
use Drupal\Core\StringTranslation\Translator\FileTranslation;
use Drupal\Core\StackMiddleware\ReverseProxyMiddleware;
@@ -839,6 +841,27 @@ function install_tasks($install_state) {
      array_slice($tasks, $key, NULL, TRUE);
  }

  if (!empty($install_state['parameters']['recipe'])) {
    // The install state indicates that we are installing from a recipe.
    $key = array_search('install_profile_modules', array_keys($tasks), TRUE);
    unset($tasks['install_profile_modules']);
    unset($tasks['install_profile_themes']);
    unset($tasks['install_install_profile']);
    $recipe_tasks = [
      'install_recipe_required_modules' => [
        'display_name' => t('Install required modules'),
        'type' => 'batch',
      ],
      'install_recipe_batch' => [
        'display_name' => t('Install recipe'),
        'type' => 'batch',
      ],
    ];
    $tasks = array_slice($tasks, 0, $key, TRUE) +
      $recipe_tasks +
      array_slice($tasks, $key, NULL, TRUE);
  }

  // Now add any tasks defined by the installation profile.
  if (!empty($install_state['parameters']['profile'])) {
    // Load the profile install file, because it is not always loaded when
@@ -2539,3 +2562,71 @@ function _install_config_locale_overrides_process_batch(array $names, array $lan
  }
  $context['finished'] = 1;
}

/**
 * Installs required modules prior to applying a recipe via the installer.
 *
 * @see install_tasks()
 *
 * @internal
 *   All installer code is internal.
 */
function install_recipe_required_modules() {
  // We need to manually trigger the installation of core-provided entity types,
  // as those will not be handled by the module installer.
  // @see install_profile_modules()
  install_core_entity_type_definitions();

  $batch_builder = new BatchBuilder();
  $batch_builder
    ->setFinishCallback([ConfigImporterBatch::class, 'finish'])
    ->setTitle(t('Installing required modules'))
    ->setInitMessage(t('Starting required module installation.'))
    ->setErrorMessage(t('Required module installation has encountered an error.'));

  $files = \Drupal::service('extension.list.module')->getList();

  // Always install required modules first.
  $required = [];

  foreach ($files as $module => $extension) {
    if (!empty($extension->info['required'])) {
      $required[$module] = $extension->sort;
    }
  }
  arsort($required);

  // The system module is already installed. See install_base_system().
  unset($required['system']);

  foreach ($required as $module => $weight) {
    $batch_builder->addOperation(
      '_install_module_batch',
      [$module, $files[$module]->info['name']],
    );
  }
  return $batch_builder->toArray();
}

/**
 * Creates a batch for the recipe system to process.
 *
 * @see install_tasks()
 *
 * @internal
 *   This API is experimental.
 */
function install_recipe_batch(&$install_state) {
  $batch_builder = new BatchBuilder();
  $batch_builder
    ->setTitle(t('Installing recipe'))
    ->setInitMessage(t('Starting recipe installation.'))
    ->setErrorMessage(t('Recipe installation has encountered an error.'));

  $recipe = Recipe::createFromDirectory($install_state['parameters']['recipe']);
  foreach (RecipeRunner::toBatchOperations($recipe) as $step) {
    $batch_builder->addOperation(...$step);
  }

  return $batch_builder->toArray();
}
+60 −26
Original line number Diff line number Diff line
@@ -48,11 +48,12 @@ public function __construct($class_loader) {
  protected function configure() {
    $this->setName('install')
      ->setDescription('Installs a Drupal demo site. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.')
      ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.')
      ->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.')
      ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in.', 'en')
      ->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name.', 'Drupal')
      ->addUsage('demo_umami --langcode fr')
      ->addUsage('standard --site-name QuickInstall');
      ->addUsage('standard --site-name QuickInstall')
      ->addUsage('core/recipes/standard --site-name RecipeBuiltSite');

    parent::configure();
  }
@@ -78,15 +79,43 @@ protected function execute(InputInterface $input, OutputInterface $output): int
      return 0;
    }

    $install_profile = $input->getArgument('install-profile');
    if ($install_profile && !$this->validateProfile($install_profile, $io)) {
      return 1;
    }
    if (!$install_profile) {
    $install_profile_or_recipe = $input->getArgument('install-profile-or-recipe');

    if (!$install_profile_or_recipe) {
      // User did not provide a recipe or install profile.
      $install_profile = $this->selectProfile($io);
    }
    // Determine if an install profile or a recipe has been provided.
    elseif ($this->validateProfile($install_profile_or_recipe)) {
      // User provided an install profile.
      $install_profile = $install_profile_or_recipe;
    }
    elseif ($this->validateRecipe($install_profile_or_recipe)) {
      // User provided a recipe.
      $recipe = $install_profile_or_recipe;
    }
    else {
      $error_msg = sprintf("'%s' is not a valid install profile or recipe.", $install_profile_or_recipe);

      // If it does not look like a path make suggestions based upon available
      // profiles.
      if (!str_contains('/', $install_profile_or_recipe)) {
        $alternatives = [];
        foreach (array_keys($this->getProfiles(TRUE, FALSE)) as $profile_name) {
          $lev = levenshtein($install_profile_or_recipe, $profile_name);
          if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile_or_recipe)) {
            $alternatives[] = $profile_name;
          }
        }
        if (!empty($alternatives)) {
          $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives));
        }
      }
      $io->getErrorStyle()->error($error_msg);
      return 1;
    }

    return $this->install($this->classLoader, $io, $install_profile, $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'));
    return $this->install($this->classLoader, $io, $install_profile ?? '', $input->getOption('langcode'), $this->getSitePath(), $input->getOption('site-name'), $recipe ?? '');
  }

  /**
@@ -123,6 +152,8 @@ protected function isDrupalInstalled() {
   *   The path to install the site to, like 'sites/default'.
   * @param string $site_name
   *   The site name.
   * @param string $recipe
   *   The recipe to use for installing.
   *
   * @throws \Exception
   *   Thrown when failing to create the $site_path directory or settings.php.
@@ -130,7 +161,7 @@ protected function isDrupalInstalled() {
   * @return int
   *   The command exit status.
   */
  protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name) {
  protected function install($class_loader, SymfonyStyle $io, $profile, $langcode, $site_path, $site_name, string $recipe) {
    $sqliteDriverNamespace = 'Drupal\\sqlite\\Driver\\Database\\sqlite';
    $password = Crypt::randomBytesBase64(12);
    $parameters = [
@@ -166,6 +197,9 @@ protected function install($class_loader, SymfonyStyle $io, $profile, $langcode,
        ],
      ],
    ];
    if ($recipe) {
      $parameters['parameters']['recipe'] = $recipe;
    }

    // Create the directory and settings.php if not there so that the installer
    // works.
@@ -277,29 +311,29 @@ protected function selectProfile(SymfonyStyle $io) {
   *
   * @param string $install_profile
   *   Install profile to validate.
   * @param \Symfony\Component\Console\Style\SymfonyStyle $io
   *   Symfony style output decorator.
   *
   * @return bool
   *   TRUE if the profile is valid, FALSE if not.
   */
  protected function validateProfile($install_profile, SymfonyStyle $io) {
  protected function validateProfile($install_profile): bool {
    // Allow people to install hidden and non-distribution profiles if they
    // supply the argument.
    $profiles = $this->getProfiles(TRUE, FALSE);
    if (!isset($profiles[$install_profile])) {
      $error_msg = sprintf("'%s' is not a valid install profile.", $install_profile);
      $alternatives = [];
      foreach (array_keys($profiles) as $profile_name) {
        $lev = levenshtein($install_profile, $profile_name);
        if ($lev <= strlen($profile_name) / 4 || str_contains($profile_name, $install_profile)) {
          $alternatives[] = $profile_name;
        }
      }
      if (!empty($alternatives)) {
        $error_msg .= sprintf(" Did you mean '%s'?", implode("' or '", $alternatives));
    return array_key_exists($install_profile, $this->getProfiles(TRUE, FALSE));
  }
      $io->getErrorStyle()->error($error_msg);

  /**
   * 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;
+4 −3
Original line number Diff line number Diff line
@@ -28,7 +28,7 @@ class QuickStartCommand extends Command {
  protected function configure() {
    $this->setName('quick-start')
      ->setDescription('Installs a Drupal site and runs a web server. This is not meant for production and might be too simple for custom development. It is a quick and easy way to get Drupal running.')
      ->addArgument('install-profile', InputArgument::OPTIONAL, 'Install profile to install the site in.')
      ->addArgument('install-profile-or-recipe', InputArgument::OPTIONAL, 'Install profile or recipe directory from which to install the site.')
      ->addOption('langcode', NULL, InputOption::VALUE_OPTIONAL, 'The language to install the site in. Defaults to en.', 'en')
      ->addOption('site-name', NULL, InputOption::VALUE_OPTIONAL, 'Set the site name. Defaults to Drupal.', 'Drupal')
      ->addOption('host', NULL, InputOption::VALUE_OPTIONAL, 'Provide a host for the server to run on. Defaults to 127.0.0.1.', '127.0.0.1')
@@ -36,7 +36,8 @@ protected function configure() {
      ->addOption('suppress-login', 's', InputOption::VALUE_NONE, 'Disable opening a login URL in a browser.')
      ->addUsage('demo_umami --langcode fr')
      ->addUsage('standard --site-name QuickInstall --host localhost --port 8080')
      ->addUsage('minimal --host my-site.com --port 80');
      ->addUsage('minimal --host my-site.com --port 80')
      ->addUsage('core/recipes/standard --site-name MyDrupalRecipe');

    parent::configure();
  }
@@ -49,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int

    $arguments = [
      'command' => 'install',
      'install-profile' => $input->getArgument('install-profile'),
      'install-profile-or-recipe' => $input->getArgument('install-profile-or-recipe'),
      '--langcode' => $input->getOption('langcode'),
      '--site-name' => $input->getOption('site-name'),
    ];
+41 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\Config\Action\Attribute;

// cspell:ignore inflector
use Drupal\Core\Config\Action\Exists;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * @internal
 *   This API is experimental.
 */
#[\Attribute(\Attribute::TARGET_METHOD)]
final class ActionMethod {

  /**
   * @param \Drupal\Core\Config\Action\Exists $exists
   *   Determines behavior of action depending on entity existence.
   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|string $adminLabel
   *   The admin label for the user interface.
   * @param bool|string $pluralize
   *   Determines whether to create a pluralized version of the method to enable
   *   the action to be called multiple times before saving the entity. The
   *   default behavior is to create an action with a plural form as determined
   *   by \Symfony\Component\String\Inflector\EnglishInflector::pluralize().
   *   For example, 'grantPermission' has a pluralized version of
   *   'grantPermissions'. If a string is provided this will be the full action
   *   ID. For example, if the method is called 'addArray' this can be set to
   *   'addMultipleArrays'. Set to FALSE if a pluralized version does not make
   *   logical sense.
   */
  public function __construct(
    public readonly Exists $exists = Exists::ErrorIfNotExists,
    public readonly TranslatableMarkup|string $adminLabel = '',
    public readonly bool|string $pluralize = TRUE
  ) {
  }

}
Loading