diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index 88a9249f5c67ca98e6f0307aea58e103eac93b58..82a0ba6218b4ca8d1d1af415f40841284c9d744b 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -4,7 +4,7 @@ services: arguments: ['@keyvalue.expirable', '@datetime.time', 24] automatic_updates.updater: class: Drupal\automatic_updates\Updater - arguments: ['@state', '@string_translation','@automatic_updates.beginner', '@automatic_updates.stager', '@automatic_updates.cleaner', '@automatic_updates.committer', '@event_dispatcher'] + arguments: ['@state', '@string_translation','@automatic_updates.beginner', '@automatic_updates.stager', '@automatic_updates.cleaner', '@automatic_updates.committer', '@event_dispatcher', '@config.factory'] automatic_updates.staged_package_validator: class: Drupal\automatic_updates\Validation\StagedProjectsValidation arguments: ['@string_translation', '@automatic_updates.updater' ] @@ -78,7 +78,7 @@ services: - { name: event_subscriber } automatic_updates.update_version_subscriber: class: Drupal\automatic_updates\Event\UpdateVersionSubscriber - arguments: ['@module_handler'] + arguments: ['@automatic_updates.updater'] tags: - { name: event_subscriber } automatic_updates.composer_executable_validator: diff --git a/src/Event/UpdateVersionSubscriber.php b/src/Event/UpdateVersionSubscriber.php index 5029b66d2f942e6f7626281a5076342acd2f5c44..a8687ae3a7c07848a86dbca3ee31971f8783b743 100644 --- a/src/Event/UpdateVersionSubscriber.php +++ b/src/Event/UpdateVersionSubscriber.php @@ -3,9 +3,9 @@ namespace Drupal\automatic_updates\Event; use Drupal\automatic_updates\AutomaticUpdatesEvents; +use Drupal\automatic_updates\Updater; use Drupal\automatic_updates\Validation\ValidationResult; use Drupal\Core\Extension\ExtensionVersion; -use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -16,15 +16,21 @@ class UpdateVersionSubscriber implements EventSubscriberInterface { use StringTranslationTrait; + /** + * The updater service. + * + * @var \Drupal\automatic_updates\Updater + */ + protected $updater; + /** * Constructs an UpdateVersionSubscriber. * - * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler - * The module handler service. + * @param \Drupal\automatic_updates\Updater $updater + * The updater service. */ - public function __construct(ModuleHandlerInterface $module_handler) { - // Load procedural functions needed for ::getCoreVersion(). - $module_handler->loadInclude('update', 'inc', 'update.compare'); + public function __construct(Updater $updater) { + $this->updater = $updater; } /** @@ -34,7 +40,11 @@ class UpdateVersionSubscriber implements EventSubscriberInterface { * The running core version as known to the Update module. */ protected function getCoreVersion(): string { - $available_updates = update_calculate_project_data(update_get_available()); + // We need to call these functions separately, because + // update_get_available() will include the file that contains + // update_calculate_project_data(). + $available_updates = update_get_available(); + $available_updates = update_calculate_project_data($available_updates); return $available_updates['drupal']['existing_version']; } @@ -46,7 +56,8 @@ class UpdateVersionSubscriber implements EventSubscriberInterface { */ public function checkUpdateVersion(PreStartEvent $event): void { $from_version = ExtensionVersion::createFromVersionString($this->getCoreVersion()); - $to_version = ExtensionVersion::createFromVersionString($event->getPackageVersions()['drupal/core']); + $core_package_name = $this->updater->getCorePackageName(); + $to_version = ExtensionVersion::createFromVersionString($event->getPackageVersions()[$core_package_name]); if ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) { $error = ValidationResult::createError([ diff --git a/src/Updater.php b/src/Updater.php index 843b5d6fe7f7b0782dbb8809ae295aa0c063b708..ca8d2c1c868bcf896d2ef43181946bfbe7874c1e 100644 --- a/src/Updater.php +++ b/src/Updater.php @@ -8,6 +8,8 @@ use Drupal\automatic_updates\Event\PreStartEvent; use Drupal\automatic_updates\Event\UpdateEvent; use Drupal\automatic_updates\Exception\UpdateException; use Drupal\Component\FileSystem\FileSystem; +use Drupal\Component\Serialization\Json; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\State\StateInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslationInterface; @@ -74,6 +76,13 @@ class Updater { */ protected $eventDispatcher; + /** + * The config factory service. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + /** * Constructs an Updater object. * @@ -91,8 +100,10 @@ class Updater { * The Composer Stager's committer service. * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. */ - public function __construct(StateInterface $state, TranslationInterface $translation, BeginnerInterface $beginner, StagerInterface $stager, CleanerInterface $cleaner, CommitterInterface $committer, EventDispatcherInterface $event_dispatcher) { + public function __construct(StateInterface $state, TranslationInterface $translation, BeginnerInterface $beginner, StagerInterface $stager, CleanerInterface $cleaner, CommitterInterface $committer, EventDispatcherInterface $event_dispatcher, ConfigFactoryInterface $config_factory) { $this->state = $state; $this->beginner = $beginner; $this->stager = $stager; @@ -100,6 +111,7 @@ class Updater { $this->committer = $committer; $this->setStringTranslation($translation); $this->eventDispatcher = $event_dispatcher; + $this->configFactory = $config_factory; } /** @@ -120,10 +132,10 @@ class Updater { * The absolute path for stage directory. */ public function getStageDirectory(): string { - // @todo This should be unique, in order to support parallel runs, or - // multiple sites on the same server. Find a way to make it unique, and - // persistent for the entire lifetime of the update process. - return FileSystem::getOsTemporaryDirectory() . '/.automatic_updates_stage'; + // Append the site ID to the directory in order to support parallel test + // runs, or multiple sites hosted on the same server. + $site_id = $this->configFactory->get('system.site')->get('uuid'); + return FileSystem::getOsTemporaryDirectory() . '/.automatic_updates_stage_' . $site_id; } /** @@ -167,7 +179,7 @@ class Updater { throw new \InvalidArgumentException("Currently only updates to Drupal core are supported."); } $packages = [ - 'drupal/core' => $project_versions['drupal'], + $this->getCorePackageName() => $project_versions['drupal'], ]; $stage_key = $this->createActiveStage($packages); /** @var \Drupal\automatic_updates\Event\PreStartEvent $event */ @@ -176,6 +188,46 @@ class Updater { return $stage_key; } + /** + * Determines the name of the core package in the project composer.json. + * + * This makes the following assumptions: + * - The vendor directory is next to the project composer.json. + * - The project composer.json contains a requirement for a core package. + * - That requirement is either for drupal/core or drupal/core-recommended. + * + * @return string + * The name of the core package (either drupal/core or + * drupal/core-recommended). + * + * @throws \RuntimeException + * If the project composer.json is not found. + * @throws \LogicException + * If the project composer.json does not contain one of the supported core + * packages. + * + * @todo Move this to an update validator, or use a more robust method of + * detecting the core package. + */ + public function getCorePackageName(): string { + $composer = realpath(static::getVendorDirectory() . '/../composer.json'); + + if (empty($composer) || !file_exists($composer)) { + throw new \RuntimeException("Could not find project-level composer.json"); + } + + $composer = file_get_contents($composer); + $composer = Json::decode($composer); + + if (isset($composer['require']['drupal/core'])) { + return 'drupal/core'; + } + elseif (isset($composer['require']['drupal/core-recommended'])) { + return 'drupal/core-recommended'; + } + throw new \LogicException("Could not determine the Drupal core package in the project-level composer.json."); + } + /** * Gets the excluded paths collected by an event object. * diff --git a/tests/src/Build/AttendedCoreRecommendedUpdateTest.php b/tests/src/Build/AttendedCoreRecommendedUpdateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..49ba1df02a25d4377416d42d75c86d27a398f0b9 --- /dev/null +++ b/tests/src/Build/AttendedCoreRecommendedUpdateTest.php @@ -0,0 +1,61 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Build; + +/** + * Tests an end-to-end core update via the core-recommended metapackage. + * + * @group automatic_updates + */ +class AttendedCoreRecommendedUpdateTest extends AttendedCoreUpdateTest { + + /** + * {@inheritdoc} + */ + protected $webRoot = 'docroot/'; + + /** + * {@inheritdoc} + */ + protected function getConfigurationForUpdate(string $version): array { + $changes = parent::getConfigurationForUpdate($version); + + // Create a fake version of drupal/core-recommended which requires the + // target version of drupal/core. + $dir = $this->copyPackage($this->getDrupalRoot() . '/composer/Metapackage/CoreRecommended'); + $this->alterPackage($dir, [ + 'version' => $version, + 'require' => [ + 'drupal/core' => $version, + ], + ]); + $changes['repositories']['drupal/core-recommended']['url'] = $dir; + + return $changes; + } + + /** + * {@inheritdoc} + */ + protected function getInitialConfiguration(): array { + $configuration = parent::getInitialConfiguration(); + + // Use drupal/core-recommended to build the test site, instead of directly + // requiring drupal/core. + $require = &$configuration['require']; + $require['drupal/core-recommended'] = $require['drupal/core']; + unset($require['drupal/core']); + + $configuration['repositories']['drupal/core-recommended'] = [ + 'type' => 'path', + 'url' => implode(DIRECTORY_SEPARATOR, [ + $this->getDrupalRoot(), + 'composer', + 'Metapackage', + 'CoreRecommended', + ]), + ]; + return $configuration; + } + +} diff --git a/tests/src/Build/AttendedCoreUpdateTest.php b/tests/src/Build/AttendedCoreUpdateTest.php index 8f1d67c647d7c8a13022d6244704b83378b8e244..000022b46136ed347d86cbbf8baabe8c9a5b6f66 100644 --- a/tests/src/Build/AttendedCoreUpdateTest.php +++ b/tests/src/Build/AttendedCoreUpdateTest.php @@ -2,8 +2,6 @@ namespace Drupal\Tests\automatic_updates\Build; -use Symfony\Component\Filesystem\Filesystem; - /** * Tests an end-to-end update of Drupal core within the UI. * @@ -11,49 +9,16 @@ use Symfony\Component\Filesystem\Filesystem; */ class AttendedCoreUpdateTest extends AttendedUpdateTestBase { - /** - * A directory containing a fake version of core that we will update to. - * - * @var string - */ - private $coreDir; - /** * {@inheritdoc} */ protected function tearDown(): void { - if ($this->destroyBuild && $this->coreDir) { - (new Filesystem())->remove($this->coreDir); + if ($this->destroyBuild) { + $this->deleteCopiedPackages(); } parent::tearDown(); } - /** - * Creates a Drupal core code base and assigns it an arbitrary version number. - * - * @param string $version - * The version number that the Drupal core code base should have. - * - * @return string - * The path of the code base. - */ - protected function createTargetCorePackage(string $version): string { - $dir = $this->getWorkspaceDirectory(); - $source = "$dir/core"; - $this->assertDirectoryExists($source); - $destination = $dir . uniqid('_core_'); - $this->assertDirectoryDoesNotExist($destination); - - $fs = new Filesystem(); - $fs->mirror($source, $destination); - - $this->setCoreVersion($destination, $version); - // This is for us to be certain that we actually update to our local, fake - // version of Drupal core. - file_put_contents($destination . '/README.txt', "Placeholder for Drupal core $version."); - return $destination; - } - /** * Modifies a Drupal core code base to set its version. * @@ -63,10 +28,7 @@ class AttendedCoreUpdateTest extends AttendedUpdateTestBase { * The version number to set. */ private function setCoreVersion(string $dir, string $version): void { - $composer = "$dir/composer.json"; - $data = $this->readJson($composer); - $data['version'] = $version; - $this->writeJson($composer, $data); + $this->alterPackage($dir, ['version' => $version]); $drupal_php = "$dir/lib/Drupal.php"; $this->assertIsWritable($drupal_php); @@ -80,7 +42,37 @@ class AttendedCoreUpdateTest extends AttendedUpdateTestBase { */ protected function createTestSite(): void { parent::createTestSite(); - $this->setCoreVersion($this->getWorkspaceDirectory() . '/core', '9.8.0'); + $this->setCoreVersion($this->getWebRoot() . '/core', '9.8.0'); + } + + /** + * Returns composer.json changes that are needed to update core. + * + * @param string $version + * The version of core we will be updating to. + * + * @return array + * The changes to merge into the test site's composer.json. + */ + protected function getConfigurationForUpdate(string $version): array { + // Create a fake version of core with the given version number, and change + // its README so that we can actually be certain that we update to this + // fake version. + $dir = $this->copyPackage($this->getWebRoot() . '/core'); + $this->setCoreVersion($dir, $version); + file_put_contents("$dir/README.txt", "Placeholder for Drupal core $version."); + + return [ + 'repositories' => [ + 'drupal/core' => [ + 'type' => 'path', + 'url' => $dir, + 'options' => [ + 'symlink' => FALSE, + ], + ], + ], + ]; } /** @@ -88,18 +80,7 @@ class AttendedCoreUpdateTest extends AttendedUpdateTestBase { */ public function test(): void { $this->createTestSite(); - $this->coreDir = $this->createTargetCorePackage('9.8.1'); - - $composer = $this->getWorkspaceDirectory() . "/composer.json"; - $data = $this->readJson($composer); - $data['repositories']['drupal/core'] = [ - 'type' => 'path', - 'url' => $this->coreDir, - 'options' => [ - 'symlink' => FALSE, - ], - ]; - $this->writeJson($composer, $data); + $this->alterPackage($this->getWorkspaceDirectory(), $this->getConfigurationForUpdate('9.8.1')); $this->installQuickStart('minimal'); $this->setReleaseMetadata(['drupal' => '0.0']); @@ -126,7 +107,7 @@ class AttendedCoreUpdateTest extends AttendedUpdateTestBase { $assert_session->pageTextContains('Update complete!'); $this->assertCoreVersion('9.8.1'); - $placeholder = file_get_contents($this->getWorkspaceDirectory() . '/core/README.txt'); + $placeholder = file_get_contents($this->getWebRoot() . '/core/README.txt'); $this->assertSame('Placeholder for Drupal core 9.8.1.', $placeholder); } diff --git a/tests/src/Build/AttendedUpdateTestBase.php b/tests/src/Build/AttendedUpdateTestBase.php index 9d2f2ef93a7f5e9b1d207bd6a6740b9176116820..0b115754d0e85e015af1de86ae6ab7b28742af03 100644 --- a/tests/src/Build/AttendedUpdateTestBase.php +++ b/tests/src/Build/AttendedUpdateTestBase.php @@ -14,6 +14,7 @@ abstract class AttendedUpdateTestBase extends QuickStartTestBase { use LocalPackagesTrait { getPackagePath as traitGetPackagePath; + copyPackage as traitCopyPackage; } use SettingsTrait; @@ -24,6 +25,13 @@ abstract class AttendedUpdateTestBase extends QuickStartTestBase { */ private $metadataServer; + /** + * The test site's document root, relative to the workspace directory. + * + * @var string + */ + protected $webRoot = './'; + /** * {@inheritdoc} */ @@ -34,6 +42,13 @@ abstract class AttendedUpdateTestBase extends QuickStartTestBase { parent::tearDown(); } + /** + * {@inheritdoc} + */ + protected function copyPackage(string $source_dir, string $destination_dir = NULL): string { + return $this->traitCopyPackage($source_dir, $destination_dir ?: $this->getWorkspaceDirectory()); + } + /** * {@inheritdoc} */ @@ -52,6 +67,16 @@ abstract class AttendedUpdateTestBase extends QuickStartTestBase { return $this->traitGetPackagePath($package); } + /** + * Returns the full path to the test site's document root. + * + * @return string + * The full path of the test site's document root. + */ + protected function getWebRoot(): string { + return $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $this->webRoot; + } + /** * Prepares the test site to serve an XML feed of available release metadata. * @@ -72,12 +97,12 @@ END; // about available updates. if (empty($this->metadataServer)) { $port = $this->findAvailablePort(); - $this->metadataServer = $this->instantiateServer($port); + $this->metadataServer = $this->instantiateServer($port, $this->webRoot); $code .= <<<END \$config['update.settings']['fetch']['url'] = 'http://localhost:$port/automatic-update-test'; END; } - $this->addSettings($code, $this->getWorkspaceDirectory()); + $this->addSettings($code, $this->getWebRoot()); } /** @@ -91,11 +116,25 @@ END; $this->assertCommandSuccessful(); } + /** + * {@inheritdoc} + */ + public function visit($request_uri = '/', $working_dir = NULL) { + return parent::visit($request_uri, $working_dir ?: $this->webRoot); + } + + /** + * {@inheritdoc} + */ + public function formLogin($username, $password, $working_dir = NULL) { + parent::formLogin($username, $password, $working_dir ?: $this->webRoot); + } + /** * {@inheritdoc} */ public function installQuickStart($profile, $working_dir = NULL) { - parent::installQuickStart($profile, $working_dir); + parent::installQuickStart($profile, $working_dir ?: $this->webRoot); // Always allow test modules to be installed in the UI and, for easier // debugging, always display errors in their dubious glory. @@ -103,25 +142,30 @@ END; \$settings['extension_discovery_scan_tests'] = TRUE; \$config['system.logging']['error_level'] = 'verbose'; END; - $this->addSettings($php, $this->getWorkspaceDirectory()); + $this->addSettings($php, $this->getWebRoot()); } /** * Uses our already-installed dependencies to build a test site to update. */ protected function createTestSite(): void { + // The project-level composer.json lives in the workspace root directory, + // which may or may not be the same directory as the web root (where Drupal + // itself lives). $composer = $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . 'composer.json'; - $this->writeJson($composer, $this->getComposerConfiguration()); + $this->writeJson($composer, $this->getInitialConfiguration()); $this->runComposer('update'); } /** - * Returns the data to write to the test site's composer.json. + * Returns the initial data to write to the test site's composer.json. * - * @return mixed[] + * This configuration will be used to build the pre-update test site. + * + * @return array * The data that should be written to the test site's composer.json. */ - protected function getComposerConfiguration(): array { + protected function getInitialConfiguration(): array { $core_constraint = preg_replace('/\.[0-9]+-dev$/', '.x-dev', \Drupal::VERSION); $drupal_root = $this->getDrupalRoot(); @@ -166,11 +210,16 @@ END; ], 'repositories' => $repositories, 'extra' => [ + 'drupal-scaffold' => [ + 'locations' => [ + 'web-root' => $this->webRoot, + ], + ], 'installer-paths' => [ - 'core' => [ + $this->webRoot . 'core' => [ 'type:drupal-core', ], - 'modules/{$name}' => [ + $this->webRoot . 'modules/{$name}' => [ 'type:drupal-module', ], ], diff --git a/tests/src/Traits/LocalPackagesTrait.php b/tests/src/Traits/LocalPackagesTrait.php index f99b7e061cc98393a4878eedbcd3e349de3fa37b..bb3516a7eebd4027e60575ee70fafd09d2b2c849 100644 --- a/tests/src/Traits/LocalPackagesTrait.php +++ b/tests/src/Traits/LocalPackagesTrait.php @@ -2,7 +2,10 @@ namespace Drupal\Tests\automatic_updates\Traits; +use Drupal\Component\FileSystem\FileSystem; +use Drupal\Component\Utility\NestedArray; use PHPUnit\Framework\Assert; +use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; /** * Provides methods for interacting with installed Composer packages. @@ -11,6 +14,16 @@ trait LocalPackagesTrait { use JsonTrait; + /** + * The paths of temporary copies of packages. + * + * @see ::copyPackage() + * @see ::deleteCopiedPackages() + * + * @var string[] + */ + private $copiedPackages = []; + /** * Returns the path of an installed package, relative to composer.json. * @@ -24,6 +37,50 @@ trait LocalPackagesTrait { return 'vendor' . DIRECTORY_SEPARATOR . $package['name']; } + /** + * Deletes all copied packages. + * + * @see ::copyPackage() + */ + protected function deleteCopiedPackages(): void { + (new SymfonyFilesystem())->remove($this->copiedPackages); + } + + /** + * Copies a package's entire directory to another location. + * + * The copies' paths will be stored so that they can be easily deleted by + * ::deleteCopiedPackages(). + * + * @param string $source_dir + * The path of the package directory to copy. + * @param string|null $destination_dir + * (optional) The directory to which the package should be copied. Will be + * suffixed with a random string to ensure uniqueness. If not given, the + * system temporary directory will be used. + * + * @return string + * The path of the temporary copy. + * + * @see ::deleteCopiedPackages() + */ + protected function copyPackage(string $source_dir, string $destination_dir = NULL): string { + Assert::assertDirectoryExists($source_dir); + + if (empty($destination_dir)) { + $destination_dir = FileSystem::getOsTemporaryDirectory(); + Assert::assertNotEmpty($destination_dir); + $destination_dir .= DIRECTORY_SEPARATOR; + } + $destination_dir = uniqid($destination_dir); + Assert::assertDirectoryDoesNotExist($destination_dir); + + (new SymfonyFilesystem())->mirror($source_dir, $destination_dir); + array_push($this->copiedPackages, $destination_dir); + + return $destination_dir; + } + /** * Generates local path repositories for a set of installed packages. * @@ -61,6 +118,21 @@ trait LocalPackagesTrait { return $repositories; } + /** + * Alters a package's composer.json file. + * + * @param string $package_dir + * The package directory. + * @param array $changes + * The changes to merge into composer.json. + */ + protected function alterPackage(string $package_dir, array $changes): void { + $composer = $package_dir . DIRECTORY_SEPARATOR . 'composer.json'; + $data = $this->readJson($composer); + $data = NestedArray::mergeDeep($data, $changes); + $this->writeJson($composer, $data); + } + /** * Reads all package information from a composer.lock file. *