From a212e6e3f2ead77a53add9367de765501434e0be Mon Sep 17 00:00:00 2001 From: tedbow <tedbow@240860.no-reply.drupal.org> Date: Wed, 1 Dec 2021 16:03:49 +0000 Subject: [PATCH] Issue #3250696 by tedbow, phenaproxima: Each stage should have its own staging directory, to avoid filesystem and locking conflicts --- automatic_updates.services.yml | 2 +- package_manager/package_manager.services.yml | 6 -- package_manager/src/Cleaner.php | 78 ------------------- package_manager/src/PathLocator.php | 3 +- package_manager/src/Stage.php | 51 ++++++++---- .../src/Functional/ExcludedPathsTest.php | 8 +- .../Kernel/PackageManagerKernelTestBase.php | 2 +- .../tests/src/Kernel/ServicesTest.php | 1 - .../tests/src/Kernel/StageEventsTest.php | 2 +- .../Functional/FileSystemOperationsTest.php | 36 ++++----- .../StagedProjectsValidatorTest.php | 29 ++++++- 11 files changed, 85 insertions(+), 133 deletions(-) delete mode 100644 package_manager/src/Cleaner.php diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index 4ad9a1001c..b54402216e 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -15,7 +15,7 @@ services: - '@package_manager.beginner' - '@package_manager.stager' - '@package_manager.committer' - - '@package_manager.cleaner' + - '@file_system' - '@event_dispatcher' - '@tempstore.shared' automatic_updates.excluded_paths_subscriber: diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml index 0ffa5aa01a..cc7e58d625 100644 --- a/package_manager/package_manager.services.yml +++ b/package_manager/package_manager.services.yml @@ -74,12 +74,6 @@ services: arguments: - '@package_manager.file_syncer' - '@package_manager.file_system' - package_manager.cleaner: - class: Drupal\package_manager\Cleaner - arguments: - - '@package_manager.file_system' - - '%site.path%' - - '@package_manager.path_locator' package_manager.path_locator: class: Drupal\package_manager\PathLocator arguments: diff --git a/package_manager/src/Cleaner.php b/package_manager/src/Cleaner.php deleted file mode 100644 index e920c259ae..0000000000 --- a/package_manager/src/Cleaner.php +++ /dev/null @@ -1,78 +0,0 @@ -<?php - -namespace Drupal\package_manager; - -use PhpTuf\ComposerStager\Domain\Cleaner as StagerCleaner; -use PhpTuf\ComposerStager\Domain\CleanerInterface; -use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; -use PhpTuf\ComposerStager\Infrastructure\Filesystem\FilesystemInterface; -use Symfony\Component\Filesystem\Filesystem; - -/** - * Defines a cleaner service that makes the staged site directory writable. - */ -class Cleaner implements CleanerInterface { - - /** - * The decorated cleaner service. - * - * @var \PhpTuf\ComposerStager\Domain\CleanerInterface - */ - protected $decorated; - - /** - * The current site path, without leading or trailing slashes. - * - * @var string - */ - protected $sitePath; - - /** - * The path locator service. - * - * @var \Drupal\package_manager\PathLocator - */ - protected $pathLocator; - - /** - * Constructs a Cleaner object. - * - * @param \PhpTuf\ComposerStager\Infrastructure\Filesystem\FilesystemInterface $file_system - * The file system service from Composer Stager. - * @param string $site_path - * The current site path (e.g., 'sites/default'), without leading or - * trailing slashes. - * @param \Drupal\package_manager\PathLocator $path_locator - * The path locator service. - */ - public function __construct(FilesystemInterface $file_system, string $site_path, PathLocator $path_locator) { - $this->decorated = new StagerCleaner($file_system); - $this->sitePath = $site_path; - $this->pathLocator = $path_locator; - } - - /** - * {@inheritdoc} - */ - public function clean(string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { - // Ensure that the staged site directory is writable so we can delete it. - $site_dir = implode(DIRECTORY_SEPARATOR, [ - $stagingDir, - $this->pathLocator->getWebRoot() ?: '.', - $this->sitePath, - ]); - - if ($this->directoryExists($site_dir)) { - (new Filesystem())->chmod($site_dir, 0777); - } - $this->decorated->clean($stagingDir, $callback, $timeout); - } - - /** - * {@inheritdoc} - */ - public function directoryExists(string $stagingDir): bool { - return $this->decorated->directoryExists($stagingDir); - } - -} diff --git a/package_manager/src/PathLocator.php b/package_manager/src/PathLocator.php index ae28ee005c..982022e3e4 100644 --- a/package_manager/src/PathLocator.php +++ b/package_manager/src/PathLocator.php @@ -52,7 +52,8 @@ class PathLocator { * Returns the path of the directory where changes should be staged. * * This directory may be made world-writeable for clean-up, so it should be - * somewhere that doesn't put the Drupal installation at risk. + * somewhere that doesn't put the Drupal installation at risk. Each staging + * area will use a sub-directory with a random name. * * @return string * The absolute path of the directory where changes should be staged. diff --git a/package_manager/src/Stage.php b/package_manager/src/Stage.php index 6f238597e9..5024089da6 100644 --- a/package_manager/src/Stage.php +++ b/package_manager/src/Stage.php @@ -3,6 +3,7 @@ namespace Drupal\package_manager; use Drupal\Component\Utility\Crypt; +use Drupal\Core\File\FileSystemInterface; use Drupal\Core\TempStore\SharedTempStoreFactory; use Drupal\package_manager\Event\PostApplyEvent; use Drupal\package_manager\Event\PostCreateEvent; @@ -17,7 +18,6 @@ use Drupal\package_manager\Exception\StageException; use Drupal\package_manager\Exception\StageOwnershipException; use Drupal\package_manager\Exception\StageValidationException; use PhpTuf\ComposerStager\Domain\BeginnerInterface; -use PhpTuf\ComposerStager\Domain\CleanerInterface; use PhpTuf\ComposerStager\Domain\CommitterInterface; use PhpTuf\ComposerStager\Domain\StagerInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -81,11 +81,11 @@ class Stage { protected $committer; /** - * The cleaner service from Composer Stager. + * The file system service. * - * @var \PhpTuf\ComposerStager\Domain\CleanerInterface + * @var \Drupal\Core\File\FileSystemInterface */ - protected $cleaner; + protected $fileSystem; /** * The event dispatcher service. @@ -121,19 +121,19 @@ class Stage { * The stager service from Composer Stager. * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $committer * The committer service from Composer Stager. - * @param \PhpTuf\ComposerStager\Domain\CleanerInterface $cleaner - * The cleaner service from Composer Stager. + * @param \Drupal\Core\File\FileSystemInterface $file_system + * The file system service. * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher service. * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_tempstore * The shared tempstore factory. */ - public function __construct(PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, CleanerInterface $cleaner, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore) { + public function __construct(PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore) { $this->pathLocator = $path_locator; $this->beginner = $beginner; $this->stager = $stager; $this->committer = $committer; - $this->cleaner = $cleaner; + $this->fileSystem = $file_system; $this->eventDispatcher = $event_dispatcher; $this->tempStore = $shared_tempstore->get('package_manager_stage'); } @@ -215,7 +215,7 @@ class Stage { $this->claim($id); $active_dir = $this->pathLocator->getActiveDirectory(); - $stage_dir = $this->pathLocator->getStageDirectory(); + $stage_dir = $this->getStageDirectory(); $event = new PreCreateEvent($this); $this->dispatch($event); @@ -244,7 +244,7 @@ class Stage { } $this->dispatch(new PreRequireEvent($this)); - $this->stager->stage($command, $this->pathLocator->getStageDirectory()); + $this->stager->stage($command, $this->getStageDirectory()); $this->dispatch(new PostRequireEvent($this)); } @@ -255,7 +255,7 @@ class Stage { $this->checkOwnership(); $active_dir = $this->pathLocator->getActiveDirectory(); - $stage_dir = $this->pathLocator->getStageDirectory(); + $stage_dir = $this->getStageDirectory(); $event = new PreApplyEvent($this); $this->dispatch($event); @@ -280,9 +280,12 @@ class Stage { } $this->dispatch(new PreDestroyEvent($this)); - $stage_dir = $this->pathLocator->getStageDirectory(); - if (is_dir($stage_dir)) { - $this->cleaner->clean($stage_dir); + // Delete all directories in parent staging directory. + $parent_stage_dir = $this->pathLocator->getStageDirectory(); + if (is_dir($parent_stage_dir)) { + $this->fileSystem->deleteRecursive($parent_stage_dir, function (string $path): void { + $this->fileSystem->chmod($path, 0777); + }); } $this->markAsAvailable(); $this->dispatch(new PostDestroyEvent($this)); @@ -354,7 +357,7 @@ class Stage { * The Composer utility object. */ public function getStageComposer(): ComposerUtility { - $dir = $this->pathLocator->getStageDirectory(); + $dir = $this->getStageDirectory(); return ComposerUtility::createForDirectory($dir); } @@ -420,4 +423,22 @@ class Stage { } } + /** + * Returns the path of the directory where changes should be staged. + * + * @return string + * The absolute path of the directory where changes should be staged. + * + * @throws \LogicException + * If this method is called before the stage has been created or claimed. + * + * @todo Make this method public in https://www.drupal.org/i/3251972. + */ + protected function getStageDirectory(): string { + if (!$this->lock) { + throw new \LogicException(__METHOD__ . '() cannot be called because the stage has not been created or claimed.'); + } + return $this->pathLocator->getStageDirectory() . DIRECTORY_SEPARATOR . $this->lock[0]; + } + } diff --git a/package_manager/tests/src/Functional/ExcludedPathsTest.php b/package_manager/tests/src/Functional/ExcludedPathsTest.php index 9de0de61ef..f3ccffdc64 100644 --- a/package_manager/tests/src/Functional/ExcludedPathsTest.php +++ b/package_manager/tests/src/Functional/ExcludedPathsTest.php @@ -59,11 +59,11 @@ class ExcludedPathsTest extends BrowserTestBase { */ public function testExcludedPaths(): void { $active_dir = __DIR__ . '/../../fixtures/fake_site'; - $stage_dir = $this->siteDirectory . '/stage'; + $parent_stage_dir = $this->siteDirectory . '/stage'; $path_locator = $this->prophesize(PathLocator::class); $path_locator->getActiveDirectory()->willReturn($active_dir); - $path_locator->getStageDirectory()->willReturn($stage_dir); + $path_locator->getStageDirectory()->willReturn($parent_stage_dir); $site_path = 'sites/example.com'; @@ -97,11 +97,11 @@ class ExcludedPathsTest extends BrowserTestBase { $this->container->get('package_manager.beginner'), $this->container->get('package_manager.stager'), $this->container->get('package_manager.committer'), - $this->container->get('package_manager.cleaner'), + $this->container->get('file_system'), $this->container->get('event_dispatcher'), $this->container->get('tempstore.shared'), ); - $stage->create(); + $stage_dir = $parent_stage_dir . DIRECTORY_SEPARATOR . $stage->create(); $this->assertDirectoryExists($stage_dir); $this->assertDirectoryNotExists("$stage_dir/sites/simpletest"); diff --git a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php index a259fcc185..6a36af152f 100644 --- a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -58,7 +58,7 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { $this->container->get('package_manager.beginner'), $this->container->get('package_manager.stager'), $this->container->get('package_manager.committer'), - $this->container->get('package_manager.cleaner'), + $this->container->get('file_system'), $this->container->get('event_dispatcher'), $this->container->get('tempstore.shared') ); diff --git a/package_manager/tests/src/Kernel/ServicesTest.php b/package_manager/tests/src/Kernel/ServicesTest.php index bb0088e225..ffa23a8a9c 100644 --- a/package_manager/tests/src/Kernel/ServicesTest.php +++ b/package_manager/tests/src/Kernel/ServicesTest.php @@ -24,7 +24,6 @@ class ServicesTest extends KernelTestBase { 'package_manager.beginner', 'package_manager.stager', 'package_manager.committer', - 'package_manager.cleaner', ]; foreach ($services as $service) { $this->assertIsObject($this->container->get($service)); diff --git a/package_manager/tests/src/Kernel/StageEventsTest.php b/package_manager/tests/src/Kernel/StageEventsTest.php index fc8a47d6f1..c5f92892fe 100644 --- a/package_manager/tests/src/Kernel/StageEventsTest.php +++ b/package_manager/tests/src/Kernel/StageEventsTest.php @@ -51,7 +51,7 @@ class StageEventsTest extends PackageManagerKernelTestBase implements EventSubsc $this->container->get('package_manager.beginner'), $this->container->get('package_manager.stager'), $this->container->get('package_manager.committer'), - $this->container->get('package_manager.cleaner'), + $this->container->get('file_system'), $this->container->get('event_dispatcher'), $this->container->get('tempstore.shared') ); diff --git a/tests/src/Functional/FileSystemOperationsTest.php b/tests/src/Functional/FileSystemOperationsTest.php index 315d59f769..1c29e1fda8 100644 --- a/tests/src/Functional/FileSystemOperationsTest.php +++ b/tests/src/Functional/FileSystemOperationsTest.php @@ -2,7 +2,6 @@ namespace Drupal\Tests\automatic_updates\Functional; -use Drupal\package_manager\Cleaner; use Drupal\automatic_updates\Updater; use Drupal\Core\Site\Settings; use Drupal\package_manager\PathLocator; @@ -60,21 +59,12 @@ class FileSystemOperationsTest extends AutomaticUpdatesFunctionalTestBase { $locator->getProjectRoot()->willReturn($drupal_root); $locator->getWebRoot()->willReturn(''); - // Create a cleaner that uses 'sites/default' as its site path, since it - // will otherwise default to the site path being used for the test site, - // which doesn't exist in the fake site fixture. - $cleaner = new Cleaner( - $this->container->get('package_manager.file_system'), - 'sites/default', - $locator->reveal() - ); - $this->updater = new Updater( $locator->reveal(), $this->container->get('package_manager.beginner'), $this->container->get('package_manager.stager'), $this->container->get('package_manager.committer'), - $cleaner, + $this->container->get('file_system'), $this->container->get('event_dispatcher'), $this->container->get('tempstore.shared') ); @@ -103,19 +93,19 @@ class FileSystemOperationsTest extends AutomaticUpdatesFunctionalTestBase { * @covers \Drupal\automatic_updates\Updater::getExclusions */ public function testExclusions(): void { - $this->updater->begin(['drupal' => '9.8.1']); - $this->assertFileDoesNotExist("$this->stageDir/sites/default/settings.php"); - $this->assertFileDoesNotExist("$this->stageDir/sites/default/settings.local.php"); - $this->assertFileDoesNotExist("$this->stageDir/sites/default/services.yml"); + $stage_id = $this->updater->begin(['drupal' => '9.8.1']); + $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/settings.php"); + $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/settings.local.php"); + $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/services.yml"); // A file in sites/default, that isn't one of the site-specific settings // files, should be staged. - $this->assertFileExists("$this->stageDir/sites/default/staged.txt"); - $this->assertDirectoryDoesNotExist("$this->stageDir/sites/simpletest"); - $this->assertDirectoryDoesNotExist("$this->stageDir/files/public"); - $this->assertDirectoryDoesNotExist("$this->stageDir/files/private"); + $this->assertFileExists("$this->stageDir/$stage_id/sites/default/staged.txt"); + $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/sites/simpletest"); + $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/files/public"); + $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/files/private"); // A file that's in the general files directory, but not in the public or // private directories, should be staged. - $this->assertFileExists("$this->stageDir/files/staged.txt"); + $this->assertFileExists("$this->stageDir/$stage_id/files/staged.txt"); } /** @@ -124,11 +114,11 @@ class FileSystemOperationsTest extends AutomaticUpdatesFunctionalTestBase { * @covers \Drupal\automatic_updates\Cleaner */ public function testClean(): void { - $this->updater->begin(['drupal' => '9.8.1']); + $stage_id = $this->updater->begin(['drupal' => '9.8.1']); // Make the staged site directory read-only, so we can test that it will be // made writable on clean-up. - $this->assertTrue(chmod("$this->stageDir/sites/default", 0400)); - $this->assertNotIsWritable("$this->stageDir/sites/default/staged.txt"); + $this->assertTrue(chmod("$this->stageDir/$stage_id/sites/default", 0400)); + $this->assertNotIsWritable("$this->stageDir/$stage_id/sites/default/staged.txt"); // If the site directory is not writable, this will throw an exception. $this->updater->destroy(); $this->assertDirectoryDoesNotExist($this->stageDir); diff --git a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php index 1d28571507..aa9a25e809 100644 --- a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php +++ b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php @@ -7,6 +7,8 @@ use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Exception\StageValidationException; use Drupal\package_manager\PathLocator; use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase; +use org\bovigo\vfs\vfsStream; +use Symfony\Component\Filesystem\Filesystem; /** * @covers \Drupal\automatic_updates\Validator\StagedProjectsValidator @@ -48,14 +50,37 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase { */ private function validate(string $active_dir, string $stage_dir): array { $locator = $this->prophesize(PathLocator::class); + $locator->getActiveDirectory()->willReturn($active_dir); $locator->getProjectRoot()->willReturn($active_dir); $locator->getVendorDirectory()->willReturn($active_dir); - $locator->getStageDirectory()->willReturn($stage_dir); + + $stage_dir_exists = is_dir($stage_dir); + if ($stage_dir_exists) { + // If we are testing a fixture with existing stage directory then we + // need to use a virtual file system directory, so we can create a + // subdirectory using the stage ID after it is created below. + $vendor = vfsStream::newDirectory('au_stage'); + $this->vfsRoot->addChild($vendor); + $locator->getStageDirectory()->willReturn($this->vfsRoot->url() . DIRECTORY_SEPARATOR . 'au_stage'); + } + else { + // If we are testing non-existent staging directory we can use the path + // directly. + $locator->getStageDirectory()->willReturn($stage_dir); + } + $this->container->set('package_manager.path_locator', $locator->reveal()); $updater = $this->container->get('automatic_updates.updater'); - $updater->begin(['drupal' => '9.8.1']); + $stage_id = $updater->begin(['drupal' => '9.8.1']); + if ($stage_dir_exists) { + // Copy the fixture's staging directory into a subdirectory using the + // stage ID as the directory name. + $sub_directory = vfsStream::newDirectory($stage_id); + $vendor->addChild($sub_directory); + (new Filesystem())->mirror($stage_dir, $sub_directory->url()); + } // The staged projects validator only runs before staged updates are // applied. Since the active and stage directories may not exist, we don't -- GitLab