From 0886b28a8b4939c379d89cc7aac6bd677f010164 Mon Sep 17 00:00:00 2001 From: phenaproxima <phenaproxima@205645.no-reply.drupal.org> Date: Tue, 28 Jun 2022 17:26:08 +0000 Subject: [PATCH] Issue #3291147 by phenaproxima, tedbow, effulgentsia, heddn: Successfully applying an update puts the remainder of the request into an unreliable state --- automatic_updates.module | 6 + automatic_updates.routing.yml | 6 + automatic_updates.services.yml | 1 + .../src/BatchProcessor.php | 19 +++ .../src/Form/UpdateReady.php | 1 + .../src/ApiController.php | 1 + ...tomaticUpdatesExtensionsKernelTestBase.php | 1 + config/schema/automatic_updates.schema.yml | 3 + package_manager/src/Stage.php | 36 +++++- .../package_manager_test_api.routing.yml | 6 + .../src/ApiController.php | 47 ++++++-- .../tests/src/Build/PackageUpdateTest.php | 28 ++--- .../Kernel/PackageManagerKernelTestBase.php | 1 + .../tests/src/Kernel/StageEventsTest.php | 2 + .../tests/src/Kernel/StageOwnershipTest.php | 2 + .../tests/src/Kernel/StageTest.php | 9 ++ src/BatchProcessor.php | 28 +++++ src/CronUpdater.php | 109 ++++++++++++++++-- src/Form/UpdateReady.php | 1 + .../src/TestController.php | 1 + tests/src/Build/UpdateTestBase.php | 30 +++-- .../Kernel/AutomaticUpdatesKernelTestBase.php | 16 +++ tests/src/Kernel/CronUpdaterTest.php | 3 + .../ReadinessValidationManagerTest.php | 1 + 24 files changed, 306 insertions(+), 52 deletions(-) diff --git a/automatic_updates.module b/automatic_updates.module index bfb47fef89..9b5d8534ae 100644 --- a/automatic_updates.module +++ b/automatic_updates.module @@ -88,6 +88,12 @@ function automatic_updates_module_implements_alter(&$implementations, $hook) { // own routes to avoid these messages while an update is in progress. unset($implementations['update']); } + if ($hook === 'cron') { + // Whatever mofo. + $hook = $implementations['automatic_updates']; + unset($implementations['automatic_updates']); + $implementations['automatic_updates'] = $hook; + } } /** diff --git a/automatic_updates.routing.yml b/automatic_updates.routing.yml index 5fa9b92850..dd408c1809 100644 --- a/automatic_updates.routing.yml +++ b/automatic_updates.routing.yml @@ -27,6 +27,12 @@ automatic_updates.finish: options: _maintenance_access: TRUE _automatic_updates_readiness_messages: skip +automatic_updates.cron.post_apply: + path: '/automatic-update/cron/post-apply/{stage_id}/{installed_version}/{target_version}/{key}' + defaults: + _controller: 'automatic_updates.cron_updater:handlePostApply' + requirements: + _access_system_cron: 'TRUE' # Links to our updater form appear in three different sets of local tasks. To ensure the breadcrumbs and paths are # consistent with the other local tasks in each set, we need two separate routes to the same form. automatic_updates.report_update: diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index 00e0493964..3e79a4a3fe 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -33,6 +33,7 @@ services: - '@logger.factory' - '@plugin.manager.mail' - '@language_manager' + - '@state' - '@config.factory' - '@package_manager.path_locator' - '@package_manager.beginner' diff --git a/automatic_updates_extensions/src/BatchProcessor.php b/automatic_updates_extensions/src/BatchProcessor.php index 49c5e3ae72..2430cc0c5c 100644 --- a/automatic_updates_extensions/src/BatchProcessor.php +++ b/automatic_updates_extensions/src/BatchProcessor.php @@ -123,6 +123,25 @@ final class BatchProcessor { } } + /** + * Calls the updater's postApply() method. + * + * @param string $stage_id + * The stage ID. + * @param array $context + * The current context of the batch job. + * + * @see \Drupal\automatic_updates\Updater::postApply() + */ + public static function postApply(string $stage_id, array &$context): void { + try { + static::getUpdater()->claim($stage_id)->postApply(); + } + catch (\Throwable $e) { + static::handleException($e, $context); + } + } + /** * Calls the updater's clean() method. * diff --git a/automatic_updates_extensions/src/Form/UpdateReady.php b/automatic_updates_extensions/src/Form/UpdateReady.php index 6fe7c737ac..76af9c14c6 100644 --- a/automatic_updates_extensions/src/Form/UpdateReady.php +++ b/automatic_updates_extensions/src/Form/UpdateReady.php @@ -162,6 +162,7 @@ final class UpdateReady extends FormBase { ->setTitle($this->t('Apply updates')) ->setInitMessage($this->t('Preparing to apply updates')) ->addOperation([BatchProcessor::class, 'commit'], [$stage_id]) + ->addOperation([BatchProcessor::class, 'postApply'], [$stage_id]) ->addOperation([BatchProcessor::class, 'clean'], [$stage_id]) ->setFinishCallback([BatchProcessor::class, 'finishCommit']) ->toArray(); diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php index fc1c2207c8..9cd99da9a9 100644 --- a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php +++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php @@ -74,6 +74,7 @@ class ApiController extends ControllerBase { $this->extensionUpdater->begin($request->get('projects', [])); $this->extensionUpdater->stage(); $this->extensionUpdater->apply(); + $this->extensionUpdater->postApply(); $this->extensionUpdater->destroy(); $dir = $this->pathLocator->getProjectRoot(); diff --git a/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php b/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php index 208095f9bc..01467ce09a 100644 --- a/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php +++ b/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php @@ -103,6 +103,7 @@ abstract class AutomaticUpdatesExtensionsKernelTestBase extends AutomaticUpdates $updater->begin($project_versions); $updater->stage(); $updater->apply(); + $updater->postApply(); $updater->destroy(); // If we did not get an exception, ensure we didn't expect any results. diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml index c9fe674982..08f5326330 100644 --- a/config/schema/automatic_updates.schema.yml +++ b/config/schema/automatic_updates.schema.yml @@ -5,6 +5,9 @@ automatic_updates.settings: cron: type: string label: 'Enable automatic updates during cron' + cron_port: + type: integer + label: 'Port to use for finalization sub-request' allow_core_minor_updates: type: boolean label: 'Allow minor level Drupal core updates' diff --git a/package_manager/src/Stage.php b/package_manager/src/Stage.php index 9c04f552d3..0f8f1c3652 100644 --- a/package_manager/src/Stage.php +++ b/package_manager/src/Stage.php @@ -338,6 +338,15 @@ class Stage { /** * Applies staged changes to the active directory. * + * After the staged changes are applied, the current request should be + * terminated as soon as possible. This is because the code loaded into the + * PHP runtime may no longer match the code that is physically present in the + * active code base, which means that the current request is running in an + * unreliable, inconsistent environment. In the next request, + * ::postApply() should be called as early as possible after Drupal is + * fully bootstrapped, to rebuild the service container, flush caches, and + * dispatch the post-apply event. + * * @param int|null $timeout * (optional) How long to allow the file copying operation to run before * timing out, in seconds, or NULL to never time out. Defaults to 600 @@ -352,15 +361,31 @@ class Stage { // If an error occurs while dispatching the events, ensure that ::destroy() // doesn't think we're in the middle of applying the staged changes to the // active directory. - $release_apply = function (): void { - $this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY); - }; - $event = new PreApplyEvent($this); $this->tempStore->set(self::TEMPSTORE_APPLY_TIME_KEY, $this->time->getRequestTime()); - $this->dispatch($event, $release_apply); + $this->dispatch($event, $this->setNotApplying()); $this->committer->commit($stage_dir, $active_dir, new PathList($event->getExcludedPaths()), NULL, $timeout); + } + + /** + * Returns a closure that marks this stage as no longer being applied. + * + * @return \Closure + * A closure that, when called, marks this stage as no longer in the process + * of being applied to the active directory. + */ + private function setNotApplying(): \Closure { + return function (): void { + $this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY); + }; + } + + /** + * Performs post-apply tasks. + */ + public function postApply(): void { + $this->checkOwnership(); // Rebuild the container and clear all caches, to ensure that new services // are picked up. @@ -370,6 +395,7 @@ class Stage { // unlikely to call newly added code during the current request. $this->eventDispatcher = \Drupal::service('event_dispatcher'); + $release_apply = $this->setNotApplying(); $this->dispatch(new PostApplyEvent($this), $release_apply); $release_apply(); } diff --git a/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml b/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml index f400c0f4fc..ec3e15596e 100644 --- a/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml +++ b/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml @@ -4,3 +4,9 @@ package_manager_test_api: _controller: 'Drupal\package_manager_test_api\ApiController::run' requirements: _access: 'TRUE' +package_manager_test_api.finish: + path: '/package-manager-test-api/finish/{id}' + defaults: + _controller: 'Drupal\package_manager_test_api\ApiController::finish' + requirements: + _access: 'TRUE' diff --git a/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/package_manager/tests/modules/package_manager_test_api/src/ApiController.php index 45eb27f0f8..35269cc57e 100644 --- a/package_manager/tests/modules/package_manager_test_api/src/ApiController.php +++ b/package_manager/tests/modules/package_manager_test_api/src/ApiController.php @@ -3,10 +3,12 @@ namespace Drupal\package_manager_test_api; use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Url; use Drupal\package_manager\PathLocator; use Drupal\package_manager\Stage; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; /** @@ -63,10 +65,10 @@ class ApiController extends ControllerBase { } /** - * Runs a complete stage life cycle. + * Begins a stage life cycle. * * Creates a staging area, requires packages into it, applies changes to the - * active directory, and destroys the stage. + * active directory. * * @param \Symfony\Component\HttpFoundation\Request $request * The request. The runtime and dev dependencies are expected to be in @@ -75,18 +77,47 @@ class ApiController extends ControllerBase { * contains an array of file paths, relative to the project root, whose * contents should be returned in the response. * - * @return \Symfony\Component\HttpFoundation\JsonResponse - * A JSON response containing an associative array of the contents of the - * files listed in the 'files_to_return' request key. The array will be - * keyed by path, relative to the project root. + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A response that directs to the ::finish() method. + * + * @see ::finish() */ - public function run(Request $request): JsonResponse { - $this->stage->create(); + public function run(Request $request): RedirectResponse { + $id = $this->stage->create(); $this->stage->require( $request->get('runtime', []), $request->get('dev', []) ); $this->stage->apply(); + + $redirect_url = Url::fromRoute('package_manager_test_api.finish') + ->setRouteParameter('id', $id) + ->setOption('query', [ + 'files_to_return' => $request->get('files_to_return', []), + ]) + ->setAbsolute() + ->toString(); + + return new RedirectResponse($redirect_url); + } + + /** + * Performs post-apply tasks and destroys the stage. + * + * @param string $id + * The stage ID. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. There may be a 'files_to_return' key in either the query + * string or request body which contains an array of file paths, relative to + * the project root, whose contents should be returned in the response. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * A JSON response containing an associative array of the contents of the + * files listed in the 'files_to_return' request key. The array will be + * keyed by path, relative to the project root. + */ + public function finish(string $id, Request $request): JsonResponse { + $this->stage->claim($id)->postApply(); $this->stage->destroy(); $dir = $this->pathLocator->getProjectRoot(); diff --git a/package_manager/tests/src/Build/PackageUpdateTest.php b/package_manager/tests/src/Build/PackageUpdateTest.php index f0016d4440..120f59b6a1 100644 --- a/package_manager/tests/src/Build/PackageUpdateTest.php +++ b/package_manager/tests/src/Build/PackageUpdateTest.php @@ -100,10 +100,11 @@ class PackageUpdateTest extends TemplateProjectTestBase { $this->assertSame($expected_pre_apply_results, $results['pre']); $expected_post_apply_results = [ - // Existing functions will still use the pre-update version. - 'return value of existing global function' => 'pre-update-value', - // New functions that were added in .module files will not be available. - 'new global function exists' => 'not exists', + // An existing functions will now return a new value. + 'return value of existing global function' => 'post-update-value', + // New functions that were added in .module files will be available + // because the post-apply event is dispatched in a new request. + 'new global function exists' => 'exists', // Definitions for existing routes should be updated. 'path of changed route' => '/updated-module/changed/post', // Routes deleted from the updated module should not be available. @@ -127,11 +128,11 @@ class PackageUpdateTest extends TemplateProjectTestBase { 'value of updated_module.added_service' => 'New service, should not exist before update', // A block removed from the updated module should not be defined anymore. 'updated_module_deleted_block block exists' => 'not exists', - // A block that was updated should have a changed definition, but an - // unchanged implementation. + // A block that was updated should have a changed definition and + // implementation. 'updated_module_updated_block block exists' => 'exists', 'updated_module_updated_block block label' => '1.1.0', - 'updated_module_updated_block block output' => '1.0.0', + 'updated_module_updated_block block output' => '1.1.0', // A block added to the module should be defined. 'updated_module_added_block block exists' => 'exists', 'updated_module_added_block block label' => 'Added block', @@ -142,14 +143,13 @@ class PackageUpdateTest extends TemplateProjectTestBase { 'updated_module_ignored_block block exists' => 'exists', 'updated_module_ignored_block block label' => '1.1.0', 'updated_module_ignored_block block output' => 'I was ignored before the update.', - // Existing class should be available. + // Existing class should still be available, and will be outputting its + // new value. 'ChangedClass exists' => 'exists', - // Existing class will still use the pre-update version. - 'value of ChangedClass' => 'Before Update', - // Classes loaded in pre-apply or before and deleted from the updated module should - // be available. - 'LoadedAndDeletedClass exists' => 'exists', - 'value of LoadedAndDeletedClass' => 'This class will be loaded and then deleted', + 'value of ChangedClass' => 'After Update', + // Classes loaded in pre-apply, but deleted from the updated module, + // should be unavailable. + 'LoadedAndDeletedClass exists' => 'not exists', // Classes not loaded before the apply operation and deleted from the updated module // should not be available. 'DeletedClass exists' => 'not exists', diff --git a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php index bd79149923..617d068d17 100644 --- a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -141,6 +141,7 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { $stage->create(); $stage->require(['drupal/core:9.8.1']); $stage->apply(); + $stage->postApply(); $stage->destroy(); // If we did not get an exception, ensure we didn't expect any results. diff --git a/package_manager/tests/src/Kernel/StageEventsTest.php b/package_manager/tests/src/Kernel/StageEventsTest.php index 6ce862a881..877dfa71ff 100644 --- a/package_manager/tests/src/Kernel/StageEventsTest.php +++ b/package_manager/tests/src/Kernel/StageEventsTest.php @@ -100,6 +100,7 @@ class StageEventsTest extends PackageManagerKernelTestBase implements EventSubsc $this->stage->create(); $this->stage->require(['ext-json:*']); $this->stage->apply(); + $this->stage->postApply(); $this->stage->destroy(); $this->assertSame($this->events, [ @@ -154,6 +155,7 @@ class StageEventsTest extends PackageManagerKernelTestBase implements EventSubsc $this->stage->create(); $this->stage->require(['ext-json:*']); $this->stage->apply(); + $this->stage->postApply(); $this->stage->destroy(); $this->fail('Expected \Drupal\package_manager\Exception\StageValidationException to be thrown.'); diff --git a/package_manager/tests/src/Kernel/StageOwnershipTest.php b/package_manager/tests/src/Kernel/StageOwnershipTest.php index c25a85930f..bcfe025be7 100644 --- a/package_manager/tests/src/Kernel/StageOwnershipTest.php +++ b/package_manager/tests/src/Kernel/StageOwnershipTest.php @@ -114,6 +114,7 @@ class StageOwnershipTest extends PackageManagerKernelTestBase { ['vendor/lib:0.0.1'], ], 'apply' => [], + 'postApply' => [], 'destroy' => [], ]; foreach ($callbacks as $method => $arguments) { @@ -179,6 +180,7 @@ class StageOwnershipTest extends PackageManagerKernelTestBase { ['vendor/lib:0.0.1'], ], 'apply' => [], + 'postApply' => [], 'destroy' => [], ]; foreach ($callbacks as $method => $arguments) { diff --git a/package_manager/tests/src/Kernel/StageTest.php b/package_manager/tests/src/Kernel/StageTest.php index 584ad1e8ac..addfa40f70 100644 --- a/package_manager/tests/src/Kernel/StageTest.php +++ b/package_manager/tests/src/Kernel/StageTest.php @@ -192,6 +192,15 @@ class StageTest extends PackageManagerKernelTestBase { $this->expectExceptionMessage('Cannot destroy the staging area while it is being applied to the active directory.'); } $stage->apply(); + + // If the stage was successfully destroyed by the event handler (i.e., the + // stage has been applying for too long and is therefore considered stale), + // the postApply() method should fail because the stage is not claimed. + if ($stage->isAvailable()) { + $this->expectException('LogicException'); + $this->expectExceptionMessage('Stage must be claimed before performing any operations on it.'); + } + $stage->postApply(); } /** diff --git a/src/BatchProcessor.php b/src/BatchProcessor.php index d7ab45cbb8..8b94d08c61 100644 --- a/src/BatchProcessor.php +++ b/src/BatchProcessor.php @@ -122,6 +122,34 @@ class BatchProcessor { public static function commit(string $stage_id, array &$context): void { try { static::getUpdater()->claim($stage_id)->apply(); + // The batch system does not allow any single request to run for longer + // than a second, so this will force the next operation to be done in a + // new request. This helps keep the running code in as consistent a state + // as possible. + // @see \Drupal\package_manager\Stage::apply() + // @see \Drupal\package_manager\Stage::postApply() + // @todo See if there's a better way to ensure the post-apply tasks run + // in a new request in https://www.drupal.org/i/3293150. + sleep(1); + } + catch (\Throwable $e) { + static::handleException($e, $context); + } + } + + /** + * Calls the updater's postApply() method. + * + * @param string $stage_id + * The stage ID. + * @param array $context + * The current context of the batch job. + * + * @see \Drupal\automatic_updates\Updater::postApply() + */ + public static function postApply(string $stage_id, array &$context): void { + try { + static::getUpdater()->claim($stage_id)->postApply(); } catch (\Throwable $e) { static::handleException($e, $context); diff --git a/src/CronUpdater.php b/src/CronUpdater.php index 3dad8ae033..d315d65d09 100644 --- a/src/CronUpdater.php +++ b/src/CronUpdater.php @@ -6,7 +6,11 @@ use Drupal\automatic_updates_9_3_shim\ProjectRelease; use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\Core\Mail\MailManagerInterface; +use Drupal\Core\State\StateInterface; +use Drupal\Core\Url; use Drupal\package_manager\Exception\StageValidationException; +use GuzzleHttp\Psr7\Uri as GuzzleUri; +use Symfony\Component\HttpFoundation\Response; /** * Defines a service that updates via cron. @@ -75,6 +79,13 @@ class CronUpdater extends Updater { */ protected $languageManager; + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + /** * Constructs a CronUpdater object. * @@ -86,15 +97,18 @@ class CronUpdater extends Updater { * The mail manager service. * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager * The language manager service. + * @param \Drupal\Core\State\StateInterface $state + * The state service. * @param mixed ...$arguments * Additional arguments to pass to the parent constructor. */ - public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, MailManagerInterface $mail_manager, LanguageManagerInterface $language_manager, ...$arguments) { + public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, MailManagerInterface $mail_manager, LanguageManagerInterface $language_manager, StateInterface $state, ...$arguments) { parent::__construct(...$arguments); $this->releaseChooser = $release_chooser; $this->logger = $logger_factory->get('automatic_updates'); $this->mailManager = $mail_manager; $this->languageManager = $language_manager; + $this->state = $state; } /** @@ -162,9 +176,91 @@ class CronUpdater extends Updater { // stage regardless of whether the update succeeds. try { // @see ::begin() - parent::begin(['drupal' => $target_version], $timeout); + $stage_id = parent::begin(['drupal' => $target_version], $timeout); $this->stage(); $this->apply(); + } + catch (\Throwable $e) { + $this->logger->error($e->getMessage()); + + // If an error occurred during the pre-create event, the stage will be + // marked as available and we shouldn't try to destroy it, since the stage + // must be claimed in order to be destroyed. + if (!$this->isAvailable()) { + $this->destroy(); + } + return; + } + + // Perform a subrequest to run ::postApply(), which needs to be done in a + // separate request. + // @see parent::apply() + $url = Url::fromRoute('automatic_updates.cron.post_apply', [ + 'stage_id' => $stage_id, + 'installed_version' => $installed_version, + 'target_version' => $target_version, + 'key' => $this->state->get('system.cron_key'), + ]); + $this->triggerPostApply($url); + } + + /** + * Executes a subrequest to run post-apply tasks. + * + * @param \Drupal\Core\Url $url + * The URL of the post-apply handler. + */ + protected function triggerPostApply(Url $url): void { + $url = $url->setAbsolute()->toString(); + + // If we're using a single-threaded web server (e.g., the built-in PHP web + // server used in build tests), allow the post-apply request to be sent to + // an alternate port. + // @todo If using the built-in PHP web server, validate that this port is + // set in https://www.drupal.org/i/3293146. + $port = $this->configFactory->get('automatic_updates.settings') + ->get('cron_port'); + if ($port) { + $url = (string) (new GuzzleUri($url))->withPort($port); + } + + // Use the bare cURL API to make the request, so that we're not relying on + // any third-party classes or other code which may have changed during the + // update. + $curl = curl_init($url); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE); + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + if ($status !== 200) { + $this->logger->error('Post-apply tasks failed with output: %status %response', [ + '%status' => $status, + '%response' => $response, + ]); + } + curl_close($curl); + } + + /** + * Runs post-apply tasks. + * + * @param string $stage_id + * The stage ID. + * @param string $installed_version + * The version of Drupal core that started the update. + * @param string $target_version + * The version of Drupal core to which we updated. + * + * @return \Symfony\Component\HttpFoundation\Response + * An empty 200 response if the post-apply tasks succeeded. + */ + public function handlePostApply(string $stage_id, string $installed_version, string $target_version): Response { + $this->claim($stage_id); + + // Run post-apply tasks in their own try-catch block so that, if anything + // raises an exception, we'll log it and proceed to destroy the stage as + // soon as possible (which is also what we do in ::performUpdate()). + try { + $this->postApply(); $this->logger->info( 'Drupal core has been updated from %previous_version to %target_version', @@ -189,13 +285,6 @@ class CronUpdater extends Updater { $this->logger->error($e->getMessage()); } - // If an error occurred during the pre-create event, the stage will be - // marked as available and we shouldn't try to destroy it, since the stage - // must be claimed in order to be destroyed. - if ($this->isAvailable()) { - return; - } - // If any pre-destroy event subscribers raise validation errors, ensure they // are formatted and logged. But if any pre- or post-destroy event // subscribers throw another exception, don't bother catching it, since it @@ -206,6 +295,8 @@ class CronUpdater extends Updater { catch (StageValidationException $e) { $this->logger->error($e->getMessage()); } + + return new Response(); } /** diff --git a/src/Form/UpdateReady.php b/src/Form/UpdateReady.php index 1867539cc7..abc4caa53f 100644 --- a/src/Form/UpdateReady.php +++ b/src/Form/UpdateReady.php @@ -199,6 +199,7 @@ class UpdateReady extends FormBase { ->setTitle($this->t('Apply updates')) ->setInitMessage($this->t('Preparing to apply updates')) ->addOperation([BatchProcessor::class, 'commit'], [$stage_id]) + ->addOperation([BatchProcessor::class, 'postApply'], [$stage_id]) ->addOperation([BatchProcessor::class, 'clean'], [$stage_id]) ->setFinishCallback([BatchProcessor::class, 'finishCommit']) ->toArray(); diff --git a/tests/modules/automatic_updates_test/src/TestController.php b/tests/modules/automatic_updates_test/src/TestController.php index 82dbb17b66..e48e80dd94 100644 --- a/tests/modules/automatic_updates_test/src/TestController.php +++ b/tests/modules/automatic_updates_test/src/TestController.php @@ -32,6 +32,7 @@ class TestController extends ControllerBase { $updater->begin(['drupal' => $to_version]); $updater->stage(); $updater->apply(); + $updater->postApply(); $updater->destroy(); // The code base has been updated, but as far as the PHP runtime is diff --git a/tests/src/Build/UpdateTestBase.php b/tests/src/Build/UpdateTestBase.php index f80214db3f..c2ef131588 100644 --- a/tests/src/Build/UpdateTestBase.php +++ b/tests/src/Build/UpdateTestBase.php @@ -42,6 +42,19 @@ abstract class UpdateTestBase extends TemplateProjectTestBase { 'automatic_updates_test_cron', 'automatic_updates_test_release_history', ]); + + // When checking for updates, we need to be able to make sub-requests, but + // the built-in PHP server is single-threaded. Therefore, open a second + // server instance on another port, which will serve the metadata about + // available updates. + $port = $this->findAvailablePort(); + $this->metadataServer = $this->instantiateServer($port); + + $code = <<<END +\$config['automatic_updates.settings']['cron_port'] = $port; +\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/test-release-history'; +END; + $this->writeSettings($code); } /** @@ -54,22 +67,7 @@ abstract class UpdateTestBase extends TemplateProjectTestBase { */ protected function setReleaseMetadata(array $xml_map): void { $xml_map = var_export($xml_map, TRUE); - $code = <<<END -\$config['update_test.settings']['xml_map'] = $xml_map; -END; - - // When checking for updates, we need to be able to make sub-requests, but - // the built-in PHP server is single-threaded. Therefore, if needed, open a - // second server instance on another port, which will serve the metadata - // about available updates. - if (empty($this->metadataServer)) { - $port = $this->findAvailablePort(); - $this->metadataServer = $this->instantiateServer($port); - $code .= <<<END -\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/test-release-history'; -END; - } - $this->writeSettings($code); + $this->writeSettings("\$config['update_test.settings']['xml_map'] = $xml_map;"); } /** diff --git a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php index 8e7046d441..532ca14047 100644 --- a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php +++ b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php @@ -5,6 +5,7 @@ namespace Drupal\Tests\automatic_updates\Kernel; use Drupal\automatic_updates\CronUpdater; use Drupal\automatic_updates\Updater; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Url; use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait; use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; use Drupal\Tests\package_manager\Kernel\TestStageTrait; @@ -53,6 +54,11 @@ abstract class AutomaticUpdatesKernelTestBase extends PackageManagerKernelTestBa if (in_array('package_manager.validator.file_system', $this->disableValidators, TRUE)) { $this->disableValidators[] = 'automatic_updates.validator.file_system_permissions'; } + // If Package Manager's symlink validator is disabled, also disable the + // Automatic Updates validator which wraps it. + if (in_array('package_manager.validator.symlink', $this->disableValidators, TRUE)) { + $this->disableValidators[] = 'automatic_updates.validator.symlink'; + } // Always disable the Xdebug validator to allow test to run with Xdebug on. $this->disableValidators[] = 'automatic_updates.validator.xdebug'; parent::setUp(); @@ -168,4 +174,14 @@ class TestCronUpdater extends CronUpdater { use TestStageTrait; + /** + * {@inheritdoc} + */ + protected function triggerPostApply(Url $url): void { + // Subrequests don't work in kernel tests, so just call the post-apply + // handler directly. + $parameters = $url->getRouteParameters(); + $this->handlePostApply($parameters['stage_id'], $parameters['installed_version'], $parameters['target_version']); + } + } diff --git a/tests/src/Kernel/CronUpdaterTest.php b/tests/src/Kernel/CronUpdaterTest.php index ab16c9dd4a..f07e16086f 100644 --- a/tests/src/Kernel/CronUpdaterTest.php +++ b/tests/src/Kernel/CronUpdaterTest.php @@ -60,6 +60,9 @@ class CronUpdaterTest extends AutomaticUpdatesKernelTestBase { $this->disableValidators[] = 'automatic_updates.validator.staged_database_updates'; $this->disableValidators[] = 'automatic_updates.staged_projects_validator'; $this->disableValidators[] = 'automatic_updates.validator.scaffold_file_permissions'; + // Since staging operations are bypassed, ignore any symbolic links in the + // running code base. + $this->disableValidators[] = 'package_manager.validator.symlink'; parent::setUp(); $this->logger = new TestLogger(); diff --git a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php index 2aff4c4ec6..d9fb9faf80 100644 --- a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php +++ b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php @@ -257,6 +257,7 @@ class ReadinessValidationManagerTest extends AutomaticUpdatesKernelTestBase { $updater->begin(['drupal' => '9.8.1']); $updater->stage(); $updater->apply(); + $updater->postApply(); $updater->destroy(); // The readiness validation manager shouldn't have any stored results. -- GitLab