From 6f26b9e2d9de93b7ef639a36a7d3a3fe68cb22a3 Mon Sep 17 00:00:00 2001 From: Ted Bowman <41201-tedbow@users.noreply.drupalcode.org> Date: Wed, 31 May 2023 04:40:19 +0000 Subject: [PATCH] Issue #3355074 by Wim Leers, phenaproxima, tedbow: Failure marker message does not contain exception message + backtrace if available --- package_manager/src/FailureMarker.php | 68 ++++++++++++++++--- package_manager/src/StageBase.php | 4 +- .../tests/src/Kernel/FailureMarkerTest.php | 53 ++++++++++++--- .../tests/src/Kernel/StageBaseTest.php | 29 ++++---- src/CronUpdateStage.php | 6 ++ tests/src/Kernel/CronUpdateStageTest.php | 4 +- .../ScaffoldFilePermissionsValidatorTest.php | 2 +- 7 files changed, 133 insertions(+), 33 deletions(-) diff --git a/package_manager/src/FailureMarker.php b/package_manager/src/FailureMarker.php index d174613379..04cf6c51a5 100644 --- a/package_manager/src/FailureMarker.php +++ b/package_manager/src/FailureMarker.php @@ -58,35 +58,87 @@ final class FailureMarker { * The stage. * @param \Drupal\Core\StringTranslation\TranslatableMarkup $message * Failure message to be added. + * @param \Throwable $throwable + * (optional) The throwable that caused the failure. */ - public function write(StageBase $stage, TranslatableMarkup $message): void { + public function write(StageBase $stage, TranslatableMarkup $message, \Throwable $throwable = NULL): void { $data = [ 'stage_class' => get_class($stage), 'stage_file' => (new \ReflectionObject($stage))->getFileName(), - 'message' => $message->render(), + 'message' => (string) $message, + 'throwable_class' => $throwable ? get_class($throwable) : FALSE, + 'throwable_message' => $throwable?->getMessage() ?? 'Not available', + 'throwable_backtrace' => $throwable?->getTraceAsString() ?? 'Not available.', ]; file_put_contents($this->getPath(), Yaml::dump($data)); } /** - * Asserts the failure file doesn't exist. + * Gets the data from the file if it exists. + * + * @return array|null + * The data from the file if it exists. * * @throws \Drupal\package_manager\Exception\StageFailureMarkerException - * Thrown if the marker file exists. + * Thrown if failure marker exists but cannot be decoded. */ - public function assertNotExists(): void { + private function getData(): ?array { $path = $this->getPath(); - if (file_exists($path)) { $data = file_get_contents($path); try { - $data = Yaml::parse($data); + return Yaml::parse($data); + } catch (ParseException $exception) { throw new StageFailureMarkerException('Failure marker file exists but cannot be decoded.', $exception->getCode(), $exception); } + } + return NULL; + } + + /** + * Gets the message from the file if it exists. + * + * @param bool $include_backtrace + * Whether to include the backtrace in the message. Defaults to TRUE. May be + * set to FALSE in a context where it does not make sense to include, such + * as e-mails. + * + * @return string|null + * The message from the file if it exists, otherwise NULL. + * + * @throws \Drupal\package_manager\Exception\StageFailureMarkerException + * Thrown if failure marker exists but cannot be decoded. + */ + public function getMessage(bool $include_backtrace = TRUE): ?string { + $data = $this->getData(); + if ($data === NULL) { + return NULL; + } + $message = $data['message']; + if ($data['throwable_class']) { + $message .= sprintf( + ' Caused by %s, with this message: %s', + $data['throwable_class'], + $data['throwable_message'], + ); + if ($include_backtrace) { + $message .= "\nBacktrace:\n" . $data['throwable_backtrace']; + } + } + return $message; + } - throw new StageFailureMarkerException($data['message']); + /** + * Asserts the failure file doesn't exist. + * + * @throws \Drupal\package_manager\Exception\StageFailureMarkerException + * Thrown if the marker file exists. + */ + public function assertNotExists(): void { + if ($message = $this->getMessage()) { + throw new StageFailureMarkerException($message); } } diff --git a/package_manager/src/StageBase.php b/package_manager/src/StageBase.php index 49d57c828e..1aa124a85b 100644 --- a/package_manager/src/StageBase.php +++ b/package_manager/src/StageBase.php @@ -470,7 +470,9 @@ abstract class StageBase implements LoggerAwareInterface { // applying, because in this situation, the site owner should probably // restore everything from a backup. $this->setNotApplying()(); - throw new ApplyFailedException($this, (string) $this->getFailureMarkerMessage(), $throwable->getCode(), $throwable); + // Update the marker file with the information from the throwable. + $this->failureMarker->write($this, $this->getFailureMarkerMessage(), $throwable); + throw new ApplyFailedException($this, $this->failureMarker->getMessage(), $throwable->getCode(), $throwable); } $this->failureMarker->clear(); $this->setMetadata(self::TEMPSTORE_CHANGES_APPLIED, TRUE); diff --git a/package_manager/tests/src/Kernel/FailureMarkerTest.php b/package_manager/tests/src/Kernel/FailureMarkerTest.php index c246845964..a56ef95759 100644 --- a/package_manager/tests/src/Kernel/FailureMarkerTest.php +++ b/package_manager/tests/src/Kernel/FailureMarkerTest.php @@ -4,7 +4,6 @@ declare(strict_types = 1); namespace Drupal\Tests\package_manager\Kernel; -use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\package_manager\Exception\StageFailureMarkerException; use Drupal\package_manager\FailureMarker; @@ -14,18 +13,42 @@ use Drupal\package_manager\FailureMarker; * @internal */ class FailureMarkerTest extends PackageManagerKernelTestBase { - use StringTranslationTrait; /** - * @covers ::assertNotExists + * @covers ::getMessage + * @testWith [true] + * [false] */ - public function testExceptionIfExists(): void { + public function testGetMessageWithoutThrowable(bool $include_backtrace): void { $failure_marker = $this->container->get(FailureMarker::class); - $failure_marker->write($this->createStage(), $this->t('Disastrous catastrophe!')); + $failure_marker->write($this->createStage(), t('Disastrous catastrophe!')); - $this->expectException(StageFailureMarkerException::class); - $this->expectExceptionMessage('Disastrous catastrophe!'); - $failure_marker->assertNotExists(); + $this->assertMatchesRegularExpression('/^Disastrous catastrophe!$/', $failure_marker->getMessage($include_backtrace)); + } + + /** + * @covers ::getMessage + * @testWith [true] + * [false] + */ + public function testGetMessageWithThrowable(bool $include_backtrace): void { + $failure_marker = $this->container->get(FailureMarker::class); + $failure_marker->write($this->createStage(), t('Disastrous catastrophe!'), new \Exception('Witchcraft!')); + + $expected_pattern = $include_backtrace + ? <<<REGEXP +/^Disastrous catastrophe! Caused by Exception, with this message: Witchcraft! +Backtrace: +#0 .*FailureMarkerTest->testGetMessageWithThrowable\(true\) +#1 .* +#2 .* +#3 .*/ +REGEXP + : '/^Disastrous catastrophe! Caused by Exception, with this message: Witchcraft!$/'; + $this->assertMatchesRegularExpression( + $expected_pattern, + $failure_marker->getMessage($include_backtrace) + ); } /** @@ -43,4 +66,18 @@ class FailureMarkerTest extends PackageManagerKernelTestBase { $failure_marker->assertNotExists(); } + /** + * Tests that the failure marker can contain an exception message. + * + * @covers ::assertNotExists + */ + public function testAssertNotExists(): void { + $failure_marker = $this->container->get(FailureMarker::class); + $failure_marker->write($this->createStage(), t('Something wicked occurred here.'), new \Exception('Witchcraft!')); + + $this->expectException(StageFailureMarkerException::class); + $this->expectExceptionMessageMatches('/^Something wicked occurred here. Caused by Exception, with this message: Witchcraft!\nBacktrace:\n#0 .*/'); + $failure_marker->assertNotExists(); + } + } diff --git a/package_manager/tests/src/Kernel/StageBaseTest.php b/package_manager/tests/src/Kernel/StageBaseTest.php index 01e4c500c3..843b4e8422 100644 --- a/package_manager/tests/src/Kernel/StageBaseTest.php +++ b/package_manager/tests/src/Kernel/StageBaseTest.php @@ -341,10 +341,14 @@ class StageBaseTest extends PackageManagerKernelTestBase { // \Drupal\package_manager\Stage::getFailureMarkerMessage() when throwing // ApplyFailedException. if ($expected_class == ApplyFailedException::class) { - $thrown_message = 'Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.'; + $class_in_message = get_class($throwable); + $thrown_message = "/^Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup. Caused by $class_in_message, with this message: A very bad thing happened\nBacktrace:\n#0 .*/"; + } + else { + $thrown_message = "/^$thrown_message$/"; } $this->assertInstanceOf($expected_class, $exception); - $this->assertSame($thrown_message, $exception->getMessage()); + $this->assertMatchesRegularExpression($thrown_message, $exception->getMessage()); $this->assertSame(123, $exception->getCode()); $failure_marker = $this->container->get(FailureMarker::class); @@ -368,14 +372,14 @@ class StageBaseTest extends PackageManagerKernelTestBase { // Make the committer throw an exception, which should cause the failure // marker to be present. - $thrown = new \Exception('Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.'); + $thrown = new \Exception('Thrown by the committer.'); LoggingCommitter::setException($thrown); try { $stage->apply(); $this->fail('Expected an exception.'); } catch (ApplyFailedException $e) { - $this->assertSame($thrown->getMessage(), $e->getMessage()); + $this->assertStringContainsString($thrown->getMessage(), $e->getMessage()); $this->assertFalse($stage->isApplying()); } $stage->destroy(); @@ -388,7 +392,7 @@ class StageBaseTest extends PackageManagerKernelTestBase { $this->fail('Expected an exception.'); } catch (StageFailureMarkerException $e) { - $this->assertSame('Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.', $e->getMessage()); + $this->assertMatchesRegularExpression('/^Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup. Caused by Exception, with this message: ' . $thrown->getMessage() . "\nBacktrace:\n#0 .*/", $e->getMessage()); $this->assertFalse($stage->isApplying()); } @@ -573,18 +577,17 @@ class StageBaseTest extends PackageManagerKernelTestBase { * @dataProvider providerFailureDuringComposerStagerOperations */ public function testFailureDuringComposerStagerOperations(string $throwing_class): void { + $exception = new \Exception("$throwing_class is angry!", 1024); + $throwing_class::setException($exception); + + $expected_message = preg_quote($exception->getMessage()); if ($throwing_class === LoggingCommitter::class) { - // If the committer throws an exception, Stage will always re-throw it - // with a predetermined failure message. - $expected_message = 'Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.'; + $expected_message = "/^Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup. Caused by Exception, with this message: $expected_message\nBacktrace:\n#0 .*/"; } else { - $expected_message = "$throwing_class is angry!"; + $expected_message = "/^$expected_message$/"; } - $exception = new \Exception($expected_message, 1024); - $throwing_class::setException($exception); - $stage = $this->createStage(); try { $stage->create(); @@ -593,7 +596,7 @@ class StageBaseTest extends PackageManagerKernelTestBase { $this->fail('Expected an exception to be thrown, but it was not.'); } catch (StageException $e) { - $this->assertSame($expected_message, $e->getMessage()); + $this->assertMatchesRegularExpression($expected_message, $e->getMessage()); $this->assertSame(1024, $e->getCode()); $this->assertSame($exception, $e->getPrevious()); } diff --git a/src/CronUpdateStage.php b/src/CronUpdateStage.php index 2f1c90d096..f305e01cab 100644 --- a/src/CronUpdateStage.php +++ b/src/CronUpdateStage.php @@ -14,6 +14,7 @@ use Drupal\Core\Url; use Drupal\package_manager\ComposerInspector; use Drupal\package_manager\Exception\ApplyFailedException; use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\Exception\StageFailureMarkerException; use Drupal\package_manager\FailureMarker; use Drupal\package_manager\PathLocator; use Drupal\package_manager\ProjectInfo; @@ -215,6 +216,11 @@ class CronUpdateStage extends UpdateStage { 'target_version' => $target_version, 'error_message' => $e->getMessage(), ]; + // Omit the backtrace in e-mails. That will be visible on the site, and is + // also stored in the failure marker. + if ($e instanceof StageFailureMarkerException || $e instanceof ApplyFailedException) { + $mail_params['error_message'] = $this->failureMarker->getMessage(FALSE); + } if ($e instanceof ApplyFailedException) { $mail_params['urgent'] = TRUE; $key = 'cron_failed_apply'; diff --git a/tests/src/Kernel/CronUpdateStageTest.php b/tests/src/Kernel/CronUpdateStageTest.php index cfc4f09ae4..efe2111c90 100644 --- a/tests/src/Kernel/CronUpdateStageTest.php +++ b/tests/src/Kernel/CronUpdateStageTest.php @@ -564,14 +564,14 @@ END; */ public function testApplyFailureEmail(): void { $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1'); - $error = new \Exception('I drink your milkshake!'); + $error = new \LogicException('I drink your milkshake!'); LoggingCommitter::setException($error); $this->container->get('cron')->run(); $expected_body = <<<END Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following error was logged: -Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup. +Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup. Caused by LogicException, with this message: {$error->getMessage()} This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported. diff --git a/tests/src/Kernel/StatusCheck/ScaffoldFilePermissionsValidatorTest.php b/tests/src/Kernel/StatusCheck/ScaffoldFilePermissionsValidatorTest.php index c7d9d2389f..9236e3eca5 100644 --- a/tests/src/Kernel/StatusCheck/ScaffoldFilePermissionsValidatorTest.php +++ b/tests/src/Kernel/StatusCheck/ScaffoldFilePermissionsValidatorTest.php @@ -345,7 +345,7 @@ class ScaffoldFilePermissionsValidatorTest extends AutomaticUpdatesKernelTestBas // If we try to overwrite any write-protected paths, even if they're not // scaffold files, we'll get an ApplyFailedException. catch (ApplyFailedException $e) { - $this->assertSame("Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.", $e->getMessage()); + $this->assertStringStartsWith("Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.", $e->getMessage()); } catch (StageEventException $e) { $this->assertExpectedResultsFromException($expected_results, $e); -- GitLab