Skip to content
Snippets Groups Projects
Commit 2d52693c authored by Adam G-H's avatar Adam G-H
Browse files

Issue #3248909 by phenaproxima, tedbow: Do not allow destroying a stage during Apply

parent e841a73c
No related branches found
No related tags found
No related merge requests found
...@@ -21,6 +21,7 @@ services: ...@@ -21,6 +21,7 @@ services:
- '@file_system' - '@file_system'
- '@event_dispatcher' - '@event_dispatcher'
- '@tempstore.shared' - '@tempstore.shared'
- '@datetime.time'
automatic_updates.cron_updater: automatic_updates.cron_updater:
class: Drupal\automatic_updates\CronUpdater class: Drupal\automatic_updates\CronUpdater
arguments: arguments:
...@@ -33,6 +34,7 @@ services: ...@@ -33,6 +34,7 @@ services:
- '@file_system' - '@file_system'
- '@event_dispatcher' - '@event_dispatcher'
- '@tempstore.shared' - '@tempstore.shared'
- '@datetime.time'
automatic_updates.staged_projects_validator: automatic_updates.staged_projects_validator:
class: Drupal\automatic_updates\Validator\StagedProjectsValidator class: Drupal\automatic_updates\Validator\StagedProjectsValidator
arguments: arguments:
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
namespace Drupal\package_manager; namespace Drupal\package_manager;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\FileSystem\FileSystem; use Drupal\Component\FileSystem\FileSystem;
use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\Crypt;
use Drupal\Core\File\Exception\FileException; use Drupal\Core\File\Exception\FileException;
...@@ -55,6 +56,16 @@ class Stage { ...@@ -55,6 +56,16 @@ class Stage {
*/ */
protected const TEMPSTORE_METADATA_KEY = 'metadata'; protected const TEMPSTORE_METADATA_KEY = 'metadata';
/**
* The tempstore key under which to store the time that ::apply() was called.
*
* @var string
*
* @see ::apply()
* @see ::destroy()
*/
private const TEMPSTORE_APPLY_TIME_KEY = 'apply_time';
/** /**
* The path locator service. * The path locator service.
* *
...@@ -104,6 +115,13 @@ class Stage { ...@@ -104,6 +115,13 @@ class Stage {
*/ */
protected $tempStore; protected $tempStore;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/** /**
* The lock info for the stage. * The lock info for the stage.
* *
...@@ -130,14 +148,17 @@ class Stage { ...@@ -130,14 +148,17 @@ class Stage {
* The event dispatcher service. * The event dispatcher service.
* @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_tempstore * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_tempstore
* The shared tempstore factory. * The shared tempstore factory.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/ */
public function __construct(PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, 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, TimeInterface $time) {
$this->pathLocator = $path_locator; $this->pathLocator = $path_locator;
$this->beginner = $beginner; $this->beginner = $beginner;
$this->stager = $stager; $this->stager = $stager;
$this->committer = $committer; $this->committer = $committer;
$this->fileSystem = $file_system; $this->fileSystem = $file_system;
$this->eventDispatcher = $event_dispatcher; $this->eventDispatcher = $event_dispatcher;
$this->time = $time;
$this->tempStore = $shared_tempstore->get('package_manager_stage'); $this->tempStore = $shared_tempstore->get('package_manager_stage');
} }
...@@ -224,7 +245,9 @@ class Stage { ...@@ -224,7 +245,9 @@ class Stage {
$stage_dir = $this->getStageDirectory(); $stage_dir = $this->getStageDirectory();
$event = new PreCreateEvent($this); $event = new PreCreateEvent($this);
$this->dispatch($event); // If an error occurs and we won't be able to create the stage, mark it as
// available.
$this->dispatch($event, [$this, 'markAsAvailable']);
$this->beginner->begin($active_dir, $stage_dir, $event->getExcludedPaths()); $this->beginner->begin($active_dir, $stage_dir, $event->getExcludedPaths());
$this->dispatch(new PostCreateEvent($this)); $this->dispatch(new PostCreateEvent($this));
...@@ -277,9 +300,16 @@ class Stage { ...@@ -277,9 +300,16 @@ class Stage {
$stage_dir = $this->getStageDirectory(); $stage_dir = $this->getStageDirectory();
$event = new PreApplyEvent($this); $event = new PreApplyEvent($this);
$this->dispatch($event); $this->tempStore->set(self::TEMPSTORE_APPLY_TIME_KEY, $this->time->getRequestTime());
// If an error occurs while dispatching the event, ensure that ::destroy()
// doesn't think we're in the middle of applying the staged changes to the
// active directory.
$this->dispatch($event, function (): void {
$this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY);
});
$this->committer->commit($stage_dir, $active_dir, $event->getExcludedPaths()); $this->committer->commit($stage_dir, $active_dir, $event->getExcludedPaths());
$this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY);
$this->dispatch(new PostApplyEvent($this)); $this->dispatch(new PostApplyEvent($this));
} }
...@@ -290,14 +320,22 @@ class Stage { ...@@ -290,14 +320,22 @@ class Stage {
* (optional) If TRUE, the staging area will be destroyed even if it is not * (optional) If TRUE, the staging area will be destroyed even if it is not
* owned by the current user or session. Defaults to FALSE. * owned by the current user or session. Defaults to FALSE.
* *
* @todo Do not allow the stage to be destroyed while it's being applied to * @throws \Drupal\package_manager\Exception\StageException
* the active directory in https://www.drupal.org/i/3248909. * If the staged changes are being applied to the active directory.
*/ */
public function destroy(bool $force = FALSE): void { public function destroy(bool $force = FALSE): void {
if (!$force) { if (!$force) {
$this->checkOwnership(); $this->checkOwnership();
} }
// If we started applying staged changes to the active directory less than
// an hour ago, prevent the stage from being destroyed.
// @see :apply()
$apply_time = $this->tempStore->get(self::TEMPSTORE_APPLY_TIME_KEY);
if (isset($apply_time) && $this->time->getRequestTime() - $apply_time < 3600) {
throw new StageException('Cannot destroy the staging area while it is being applied to the active directory.');
}
$this->dispatch(new PreDestroyEvent($this)); $this->dispatch(new PreDestroyEvent($this));
// Delete all directories in parent staging directory. // Delete all directories in parent staging directory.
$parent_stage_dir = static::getStagingRoot(); $parent_stage_dir = static::getStagingRoot();
...@@ -332,13 +370,16 @@ class Stage { ...@@ -332,13 +370,16 @@ class Stage {
* *
* @param \Drupal\package_manager\Event\StageEvent $event * @param \Drupal\package_manager\Event\StageEvent $event
* The event object. * The event object.
* @param callable $on_error
* (optional) A callback function to call if an error occurs, before any
* exceptions are thrown.
* *
* @throws \Drupal\package_manager\Exception\StageValidationException * @throws \Drupal\package_manager\Exception\StageValidationException
* If the event collects any validation errors. * If the event collects any validation errors.
* @throws \Drupal\package_manager\Exception\StageException * @throws \Drupal\package_manager\Exception\StageException
* If any other sort of error occurs. * If any other sort of error occurs.
*/ */
protected function dispatch(StageEvent $event): void { protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
try { try {
$this->eventDispatcher->dispatch($event); $this->eventDispatcher->dispatch($event);
...@@ -354,10 +395,8 @@ class Stage { ...@@ -354,10 +395,8 @@ class Stage {
} }
if (isset($error)) { if (isset($error)) {
// If we won't be able to create the staging area, mark it as available. if ($on_error) {
// @see ::create() $on_error();
if ($event instanceof PreCreateEvent) {
$this->markAsAvailable();
} }
throw $error; throw $error;
} }
......
...@@ -42,6 +42,7 @@ class ApiController extends ControllerBase { ...@@ -42,6 +42,7 @@ class ApiController extends ControllerBase {
$container->get('file_system'), $container->get('file_system'),
$container->get('event_dispatcher'), $container->get('event_dispatcher'),
$container->get('tempstore.shared'), $container->get('tempstore.shared'),
$container->get('datetime.time')
); );
return new static($stage); return new static($stage);
} }
......
...@@ -44,6 +44,19 @@ class TestSubscriber implements EventSubscriberInterface { ...@@ -44,6 +44,19 @@ class TestSubscriber implements EventSubscriberInterface {
$this->state = $state; $this->state = $state;
} }
/**
* Sets whether a specific event will call exit().
*
* This is useful for simulating an unrecoverable (fatal) error when handling
* the given event.
*
* @param string $event
* The event class.
*/
public static function setExit(string $event): void {
\Drupal::state()->set(static::STATE_KEY . ".$event", 'exit');
}
/** /**
* Sets validation results for a specific event. * Sets validation results for a specific event.
* *
...@@ -102,6 +115,9 @@ class TestSubscriber implements EventSubscriberInterface { ...@@ -102,6 +115,9 @@ class TestSubscriber implements EventSubscriberInterface {
if ($results instanceof \Throwable) { if ($results instanceof \Throwable) {
throw $results; throw $results;
} }
elseif ($results === 'exit') {
exit();
}
/** @var \Drupal\package_manager\ValidationResult $result */ /** @var \Drupal\package_manager\ValidationResult $result */
foreach ($results as $result) { foreach ($results as $result) {
if ($result->getSeverity() === SystemManager::REQUIREMENT_ERROR) { if ($result->getSeverity() === SystemManager::REQUIREMENT_ERROR) {
......
...@@ -78,7 +78,8 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { ...@@ -78,7 +78,8 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase {
$this->container->get('package_manager.committer'), $this->container->get('package_manager.committer'),
$this->container->get('file_system'), $this->container->get('file_system'),
$this->container->get('event_dispatcher'), $this->container->get('event_dispatcher'),
$this->container->get('tempstore.shared') $this->container->get('tempstore.shared'),
$this->container->get('datetime.time')
); );
} }
...@@ -297,9 +298,9 @@ class TestStage extends Stage { ...@@ -297,9 +298,9 @@ class TestStage extends Stage {
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected function dispatch(StageEvent $event): void { protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
try { try {
parent::dispatch($event); parent::dispatch($event, $on_error);
} }
catch (StageException $e) { catch (StageException $e) {
// Attach the event object to the exception so that test code can verify // Attach the event object to the exception so that test code can verify
......
...@@ -2,6 +2,11 @@ ...@@ -2,6 +2,11 @@
namespace Drupal\Tests\package_manager\Kernel; namespace Drupal\Tests\package_manager\Kernel;
use Drupal\Component\Datetime\Time;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Exception\StageException;
/** /**
* @coversDefaultClass \Drupal\package_manager\Stage * @coversDefaultClass \Drupal\package_manager\Stage
* *
...@@ -9,6 +14,16 @@ namespace Drupal\Tests\package_manager\Kernel; ...@@ -9,6 +14,16 @@ namespace Drupal\Tests\package_manager\Kernel;
*/ */
class StageTest extends PackageManagerKernelTestBase { class StageTest extends PackageManagerKernelTestBase {
/**
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
parent::register($container);
$container->getDefinition('datetime.time')
->setClass(TestTime::class);
}
/** /**
* @covers ::getStageDirectory * @covers ::getStageDirectory
*/ */
...@@ -27,4 +42,78 @@ class StageTest extends PackageManagerKernelTestBase { ...@@ -27,4 +42,78 @@ class StageTest extends PackageManagerKernelTestBase {
$this->createStage()->getStageDirectory(); $this->createStage()->getStageDirectory();
} }
/**
* Data provider for ::testDestroyDuringApply().
*
* @return array[]
* Sets of arguments to pass to the test method.
*/
public function providerDestroyDuringApply(): array {
return [
'force destroy, not stale' => [TRUE, 1, TRUE],
'regular destroy, not stale' => [FALSE, 1, TRUE],
'force destroy, stale' => [TRUE, 7200, FALSE],
'regular destroy, stale' => [FALSE, 7200, FALSE],
];
}
/**
* Tests destroying a stage while applying it.
*
* @param bool $force
* Whether or not the stage should be force destroyed.
* @param int $time_offset
* How many simulated seconds should have elapsed between the PreApplyEvent
* being dispatched and the attempt to destroy the stage.
* @param bool $expect_exception
* Whether or not destroying the stage will raise an exception.
*
* @dataProvider providerDestroyDuringApply
*/
public function testDestroyDuringApply(bool $force, int $time_offset, bool $expect_exception): void {
$listener = function (PreApplyEvent $event) use ($force, $time_offset): void {
// Simulate that a certain amount of time has passed since we started
// applying staged changes. After a point, it should be possible to
// destroy the stage even if it hasn't finished.
TestTime::$offset = $time_offset;
// No real-life event subscriber should try to destroy the stage while
// handling another event. The only reason we're doing it here is to
// simulate an attempt to destroy the stage while it's being applied, for
// testing purposes.
$event->getStage()->destroy($force);
};
$this->container->get('event_dispatcher')
->addListener(PreApplyEvent::class, $listener);
$stage = $this->createStage();
$stage->create();
if ($expect_exception) {
$this->expectException(StageException::class);
$this->expectExceptionMessage('Cannot destroy the staging area while it is being applied to the active directory.');
}
$stage->apply();
}
}
/**
* A test-only implementation of the time service.
*/
class TestTime extends Time {
/**
* An offset to add to the request time.
*
* @var int
*/
public static $offset = 0;
/**
* {@inheritdoc}
*/
public function getRequestTime() {
return parent::getRequestTime() + static::$offset;
}
} }
...@@ -11,6 +11,7 @@ use Drupal\Core\Form\FormBase; ...@@ -11,6 +11,7 @@ use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\State\StateInterface; use Drupal\Core\State\StateInterface;
use Drupal\package_manager\Exception\StageException;
use Drupal\package_manager\Exception\StageOwnershipException; use Drupal\package_manager\Exception\StageOwnershipException;
use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\ContainerInterface;
...@@ -193,9 +194,14 @@ class UpdateReady extends FormBase { ...@@ -193,9 +194,14 @@ class UpdateReady extends FormBase {
* Cancels the in-progress update. * Cancels the in-progress update.
*/ */
public function cancel(array &$form, FormStateInterface $form_state): void { public function cancel(array &$form, FormStateInterface $form_state): void {
$this->updater->destroy(); try {
$this->messenger()->addStatus($this->t('The update was successfully cancelled.')); $this->updater->destroy();
$form_state->setRedirect('automatic_updates.report_update'); $this->messenger()->addStatus($this->t('The update was successfully cancelled.'));
$form_state->setRedirect('automatic_updates.report_update');
}
catch (StageException $e) {
$this->messenger()->addError($e->getMessage());
}
} }
} }
...@@ -14,6 +14,7 @@ use Drupal\Core\Form\FormStateInterface; ...@@ -14,6 +14,7 @@ use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link; use Drupal\Core\Link;
use Drupal\Core\State\StateInterface; use Drupal\Core\State\StateInterface;
use Drupal\Core\Url; use Drupal\Core\Url;
use Drupal\package_manager\Exception\StageException;
use Drupal\package_manager\Exception\StageOwnershipException; use Drupal\package_manager\Exception\StageOwnershipException;
use Drupal\system\SystemManager; use Drupal\system\SystemManager;
use Drupal\update\UpdateManagerInterface; use Drupal\update\UpdateManagerInterface;
...@@ -276,8 +277,13 @@ class UpdaterForm extends FormBase { ...@@ -276,8 +277,13 @@ class UpdaterForm extends FormBase {
* Submit function to delete an existing in-progress update. * Submit function to delete an existing in-progress update.
*/ */
public function deleteExistingUpdate(): void { public function deleteExistingUpdate(): void {
$this->updater->destroy(TRUE); try {
$this->messenger()->addMessage($this->t("Staged update deleted")); $this->updater->destroy(TRUE);
$this->messenger()->addMessage($this->t("Staged update deleted"));
}
catch (StageException $e) {
$this->messenger()->addError($e->getMessage());
}
} }
/** /**
......
...@@ -91,9 +91,9 @@ class Updater extends Stage { ...@@ -91,9 +91,9 @@ class Updater extends Stage {
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected function dispatch(StageEvent $event): void { protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
try { try {
parent::dispatch($event); parent::dispatch($event, $on_error);
} }
catch (StageValidationException $e) { catch (StageValidationException $e) {
throw new UpdateException($e->getResults(), $e->getMessage() ?: "Unable to complete the update because of errors.", $e->getCode(), $e); throw new UpdateException($e->getResults(), $e->getMessage() ?: "Unable to complete the update because of errors.", $e->getCode(), $e);
......
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
namespace Drupal\Tests\automatic_updates\Functional; namespace Drupal\Tests\automatic_updates\Functional;
use Drupal\automatic_updates\Event\ReadinessCheckEvent; use Drupal\automatic_updates\Event\ReadinessCheckEvent;
use Drupal\automatic_updates_test\Datetime\TestTime;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\ValidationResult; use Drupal\package_manager\ValidationResult;
use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1; use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
...@@ -240,6 +242,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase { ...@@ -240,6 +242,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
*/ */
public function testDeleteExistingUpdate(): void { public function testDeleteExistingUpdate(): void {
$conflict_message = 'Cannot begin an update because another Composer operation is currently in progress.'; $conflict_message = 'Cannot begin an update because another Composer operation is currently in progress.';
$cancelled_message = 'The update was successfully cancelled.';
$assert_session = $this->assertSession(); $assert_session = $this->assertSession();
$page = $this->getSession()->getPage(); $page = $this->getSession()->getPage();
...@@ -263,7 +266,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase { ...@@ -263,7 +266,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
// Delete the existing update. // Delete the existing update.
$page->pressButton('Cancel update'); $page->pressButton('Cancel update');
$assert_session->addressEquals('/admin/reports/updates/automatic-update'); $assert_session->addressEquals('/admin/reports/updates/automatic-update');
$assert_session->pageTextContains('The update was successfully cancelled.'); $assert_session->pageTextContains($cancelled_message);
$assert_session->pageTextNotContains($conflict_message); $assert_session->pageTextNotContains($conflict_message);
// Ensure we can start another update after deleting the existing one. // Ensure we can start another update after deleting the existing one.
$page->pressButton('Update'); $page->pressButton('Update');
...@@ -281,12 +284,60 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase { ...@@ -281,12 +284,60 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
$this->drupalGet('/admin/reports/updates/automatic-update'); $this->drupalGet('/admin/reports/updates/automatic-update');
$assert_session->pageTextContains($conflict_message); $assert_session->pageTextContains($conflict_message);
$assert_session->buttonNotExists('Update'); $assert_session->buttonNotExists('Update');
// We should be able to delete the previous update, and then we should be // We should be able to delete the previous update, then start a new one.
// able to start a new one.
$page->pressButton('Delete existing update'); $page->pressButton('Delete existing update');
$assert_session->pageTextContains('Staged update deleted'); $assert_session->pageTextContains('Staged update deleted');
$assert_session->pageTextNotContains($conflict_message); $assert_session->pageTextNotContains($conflict_message);
$assert_session->buttonExists('Update'); $page->pressButton('Update');
$this->checkForMetaRefresh();
$this->assertUpdateReady();
// Stop execution during pre-apply. This should make Package Manager think
// the staged changes are being applied and raise an error if we try to
// cancel the update.
TestSubscriber1::setExit(PreApplyEvent::class);
$page->pressButton('Continue');
$this->checkForMetaRefresh();
$page->clickLink('the error page');
$page->pressButton('Cancel update');
// The exception should have been caught and displayed in the messages area.
$assert_session->statusCodeEquals(200);
$destroy_error = 'Cannot destroy the staging area while it is being applied to the active directory.';
$assert_session->pageTextContains($destroy_error);
$assert_session->pageTextNotContains($cancelled_message);
// We should get the same error if we log in as another user and try to
// delete the staged update.
$this->drupalLogin($this->rootUser);
$this->drupalGet('/admin/reports/updates/automatic-update');
$assert_session->pageTextContains($conflict_message);
$page->pressButton('Delete existing update');
$assert_session->statusCodeEquals(200);
$assert_session->pageTextContains($destroy_error);
$assert_session->pageTextNotContains('Staged update deleted');
// Two hours later, Package Manager should consider the stage to be stale,
// allowing the staged update to be deleted.
TestTime::setFakeTimeByOffset('+2 hours');
$this->getSession()->reload();
$assert_session->pageTextContains($conflict_message);
$page->pressButton('Delete existing update');
$assert_session->statusCodeEquals(200);
$assert_session->pageTextContains('Staged update deleted');
// If a legitimate error is raised during pre-apply, we should be able to
// delete the staged update right away.
$this->createTestValidationResults();
$results = $this->testResults['checker_1']['1 error'];
TestSubscriber1::setTestResult($results, PreApplyEvent::class);
$page->pressButton('Update');
$this->checkForMetaRefresh();
$this->assertUpdateReady();
$page->pressButton('Continue');
$this->checkForMetaRefresh();
$page->clickLink('the error page');
$page->pressButton('Cancel update');
$assert_session->pageTextContains($cancelled_message);
} }
/** /**
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment