From 4ced6730cda3d7d71ad072556a7acda1f81744f1 Mon Sep 17 00:00:00 2001 From: Kunal Sachdev <57170-kunal.sachdev@users.noreply.drupalcode.org> Date: Wed, 14 Jun 2023 18:40:46 +0000 Subject: [PATCH] Issue #3355628 by kunal.sachdev, phenaproxima, Wim Leers, tedbow, omkar.podey, wendyZ: Package Manager should keep an audit log of changes it applied to the active codebase --- .../tests/src/Build/ModuleUpdateTest.php | 4 + package_manager/package_manager.services.yml | 9 + .../src/EventSubscriber/ChangeLogger.php | 191 ++++++++++++++++++ .../src/PackageManagerServiceProvider.php | 1 + package_manager/src/StageBase.php | 8 +- .../tests/src/Build/PackageInstallTest.php | 2 + .../tests/src/Build/PackageUpdateTest.php | 2 + .../src/Build/TemplateProjectTestBase.php | 62 ++++++ .../tests/src/Kernel/ChangeLoggerTest.php | 102 ++++++++++ .../tests/src/Kernel/StageBaseTest.php | 37 ++++ tests/src/Build/CoreUpdateTest.php | 19 ++ .../Kernel/AutomaticUpdatesKernelTestBase.php | 23 --- 12 files changed, 434 insertions(+), 26 deletions(-) create mode 100644 package_manager/src/EventSubscriber/ChangeLogger.php create mode 100644 package_manager/tests/src/Kernel/ChangeLoggerTest.php diff --git a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php index dcbd01d1f1..adf830b09b 100644 --- a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php +++ b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php @@ -94,6 +94,8 @@ END; $module_composer_json = json_decode($file_contents['web/modules/contrib/alpha/composer.json']); $this->assertSame('1.1.0', $module_composer_json?->version); + $this->assertRequestedChangesWereLogged(['Update drupal/alpha from 1.0.0 to 1.1.0']); + $this->assertAppliedChangesWereLogged(['Updated drupal/alpha from 1.0.0 to 1.1.0']); } /** @@ -131,6 +133,8 @@ END; $this->waitForBatchJob(); $assert_session->pageTextContains('Update complete!'); $this->assertModuleVersion('alpha', '1.1.0'); + $this->assertRequestedChangesWereLogged(['Update drupal/alpha from 1.0.0 to 1.1.0']); + $this->assertAppliedChangesWereLogged(['Updated drupal/alpha from 1.0.0 to 1.1.0']); } /** diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml index 6f35313275..3e6a7f50c5 100644 --- a/package_manager/package_manager.services.yml +++ b/package_manager/package_manager.services.yml @@ -26,6 +26,10 @@ services: parent: logger.channel_base arguments: - 'package_manager' + logger.channel.package_manager_change_log: + parent: logger.channel_base + arguments: + - 'package_manager_change_log' # Services provided to Drupal by Package Manager. package_manager.beginner: @@ -53,6 +57,11 @@ services: autowire: false tags: - { name: event_subscriber } + Drupal\package_manager\EventSubscriber\ChangeLogger: + calls: + - [setLogger, ['@logger.channel.package_manager_change_log']] + tags: + - { name: event_subscriber } package_manager.composer_inspector: class: Drupal\package_manager\ComposerInspector calls: diff --git a/package_manager/src/EventSubscriber/ChangeLogger.php b/package_manager/src/EventSubscriber/ChangeLogger.php new file mode 100644 index 0000000000..b651424db4 --- /dev/null +++ b/package_manager/src/EventSubscriber/ChangeLogger.php @@ -0,0 +1,191 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager\EventSubscriber; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\PathLocator; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Event subscriber to log changes that happen during the stage life cycle. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ChangeLogger implements EventSubscriberInterface, LoggerAwareInterface { + + use LoggerAwareTrait; + use StringTranslationTrait; + + /** + * The metadata key under which to store the installed packages at start. + * + * @var string + * + * @see ::recordInstalledPackages() + */ + private const INSTALLED_PACKAGES_KEY = 'package_manager_installed_packages'; + + /** + * The metadata key under which to store the requested package versions. + * + * @var string + * + * @see ::recordRequestedPackageVersions() + */ + private const REQUESTED_PACKAGES_KEY = 'package_manager_requested_packages'; + + /** + * Constructs a ChangeLogger object. + * + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + public function __construct( + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + ) {} + + /** + * Records packages installed in the project root. + * + * We need to do this before the stage has been created, so that we have a + * complete picture of which requested packages are merely being updated, and + * which are being newly added. Once the stage has been created, the installed + * packages won't change -- if they do, a validation error will be raised. + * + * @param \Drupal\package_manager\Event\PreCreateEvent $event + * The event being handled. + * + * @see \Drupal\package_manager\Validator\LockFileValidator + */ + public function recordInstalledPackages(PreCreateEvent $event): void { + $packages = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); + $event->stage->setMetadata(static::INSTALLED_PACKAGES_KEY, $packages); + } + + /** + * Records requested packages. + * + * @param \Drupal\package_manager\Event\PreRequireEvent $event + * The event object. + */ + public function recordRequestedPackageVersions(PostRequireEvent $event): void { + // There could be multiple require operations, so overlay the requested + // packages from the current operation onto the requested packages from any + // previous require operation. + $requested_packages = array_merge( + $event->stage->getMetadata(static::REQUESTED_PACKAGES_KEY) ?? [], + $event->getRuntimePackages(), + $event->getDevPackages(), + ); + $event->stage->setMetadata(static::REQUESTED_PACKAGES_KEY, $requested_packages); + } + + /** + * Logs all Package Manager changes. + * + * @param \Drupal\package_manager\Event\PostApplyEvent $event + * The event being handled. + */ + public function logChanges(PostApplyEvent $event): void { + $installed_at_start = $event->stage->getMetadata(static::INSTALLED_PACKAGES_KEY); + $installed_post_apply = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot()); + + // Compare the packages which were installed when the stage was created + // against the package versions that were requested over all of the stage's + // require operations, and create a log entry listing all of it. + $requested_log = []; + + $requested_packages = $event->stage->getMetadata(static::REQUESTED_PACKAGES_KEY) ?? []; + // Sort the requested packages by name, to make it easier to review a large + // change list. + ksort($requested_packages, SORT_NATURAL); + foreach ($requested_packages as $name => $constraint) { + $installed_version = $installed_at_start[$name]?->version; + if ($installed_version === NULL) { + // For clarity, make the "any version" constraint human-readable. + if ($constraint === '*') { + $constraint = $this->t('* (any version)'); + } + $requested_log[] = $this->t('- Install @name @constraint', [ + '@name' => $name, + '@constraint' => $constraint, + ]); + } + else { + $requested_log[] = $this->t('- Update @name from @installed_version to @constraint', [ + '@name' => $name, + '@installed_version' => $installed_version, + '@constraint' => $constraint, + ]); + } + } + // It's possible that $requested_log will be empty -- for example, a custom + // stage that only does removals, or some other operation, and never + // dispatches PostRequireEvent. + if ($requested_log) { + $message = $this->t("Requested changes:\n@change_list", [ + '@change_list' => implode("\n", array_map('strval', $requested_log)), + ]); + $this->logger->info($message); + } + + // Create a separate log entry listing everything that actually changed. + $applied_log = []; + + $updated_packages = $installed_post_apply->getPackagesWithDifferentVersionsIn($installed_at_start); + // Sort the packages by name to make it easier to review large change sets. + $updated_packages->ksort(SORT_NATURAL); + foreach ($updated_packages as $name => $package) { + $applied_log[] = $this->t('- Updated @name from @installed_version to @updated_version', [ + '@name' => $name, + '@installed_version' => $installed_at_start[$name]->version, + '@updated_version' => $package->version, + ]); + } + + $added_packages = $installed_post_apply->getPackagesNotIn($installed_at_start); + $added_packages->ksort(SORT_NATURAL); + foreach ($added_packages as $name => $package) { + $applied_log[] = $this->t('- Installed @name @version', [ + '@name' => $name, + '@version' => $package->version, + ]); + } + + $removed_packages = $installed_at_start->getPackagesNotIn($installed_post_apply); + $removed_packages->ksort(SORT_NATURAL); + foreach ($installed_at_start->getPackagesNotIn($installed_post_apply) as $name => $package) { + $applied_log[] = $this->t('- Uninstalled @name', ['@name' => $name]); + } + $message = $this->t("Applied changes:\n@change_list", [ + '@change_list' => implode("\n", array_map('strval', $applied_log)), + ]); + $this->logger->info($message); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => ['recordInstalledPackages'], + PostRequireEvent::class => ['recordRequestedPackageVersions'], + PostApplyEvent::class => ['logChanges'], + ]; + } + +} diff --git a/package_manager/src/PackageManagerServiceProvider.php b/package_manager/src/PackageManagerServiceProvider.php index 6230aafd93..b6f5ccb513 100644 --- a/package_manager/src/PackageManagerServiceProvider.php +++ b/package_manager/src/PackageManagerServiceProvider.php @@ -109,6 +109,7 @@ final class PackageManagerServiceProvider extends ServiceProviderBase { 'request_stack' => 'Symfony\Component\HttpFoundation\RequestStack', 'theme_handler' => 'Drupal\Core\Extension\ThemeHandlerInterface', 'cron' => 'Drupal\Core\CronInterface', + 'logger.factory' => 'Drupal\Core\Logger\LoggerChannelFactoryInterface', ]; foreach ($aliases as $service_id => $alias) { if (!$container->hasAlias($alias)) { diff --git a/package_manager/src/StageBase.php b/package_manager/src/StageBase.php index ad88ff93be..d4f7a8c866 100644 --- a/package_manager/src/StageBase.php +++ b/package_manager/src/StageBase.php @@ -223,7 +223,7 @@ abstract class StageBase implements LoggerAwareInterface { * @return mixed * The metadata value, or NULL if it is not set. */ - protected function getMetadata(string $key) { + public function getMetadata(string $key) { $this->checkOwnership(); $metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY) ?: []; @@ -237,11 +237,13 @@ abstract class StageBase implements LoggerAwareInterface { * claimed by its owner, or created during the current request. * * @param string $key - * The key under which to store the metadata. + * The key under which to store the metadata. To prevent conflicts, it is + * strongly recommended that this be prefixed with the name of the module + * storing the data. * @param mixed $data * The metadata to store. */ - protected function setMetadata(string $key, $data): void { + public function setMetadata(string $key, $data): void { $this->checkOwnership(); $metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY); diff --git a/package_manager/tests/src/Build/PackageInstallTest.php b/package_manager/tests/src/Build/PackageInstallTest.php index 462a158436..dcf55efaf0 100644 --- a/package_manager/tests/src/Build/PackageInstallTest.php +++ b/package_manager/tests/src/Build/PackageInstallTest.php @@ -42,6 +42,8 @@ class PackageInstallTest extends TemplateProjectTestBase { ] ); $this->assertArrayHasKey('web/modules/contrib/alpha/composer.json', $file_contents); + $this->assertRequestedChangesWereLogged(['Install drupal/alpha 1.0.0']); + $this->assertAppliedChangesWereLogged(['Installed drupal/alpha 1.0.0']); } } diff --git a/package_manager/tests/src/Build/PackageUpdateTest.php b/package_manager/tests/src/Build/PackageUpdateTest.php index 1f05874a8e..39afdf2377 100644 --- a/package_manager/tests/src/Build/PackageUpdateTest.php +++ b/package_manager/tests/src/Build/PackageUpdateTest.php @@ -76,6 +76,8 @@ class PackageUpdateTest extends TemplateProjectTestBase { $this->assertSame('Bravo!', $file_contents['bravo.txt']); $this->assertExpectedStageEventsFired(ControllerStage::class); + $this->assertRequestedChangesWereLogged(['Update drupal/updated_module from 1.0.0 to 1.1.0']); + $this->assertAppliedChangesWereLogged(['Updated drupal/updated_module from 1.0.0 to 1.1.0']); } } diff --git a/package_manager/tests/src/Build/TemplateProjectTestBase.php b/package_manager/tests/src/Build/TemplateProjectTestBase.php index 317a8173da..5d73aedd29 100644 --- a/package_manager/tests/src/Build/TemplateProjectTestBase.php +++ b/package_manager/tests/src/Build/TemplateProjectTestBase.php @@ -604,6 +604,68 @@ END; $this->assertSame($expected_titles, $actual_titles, $message ?? ''); } + /** + * Visits the 'admin/reports/dblog' and selects Package Manager's change log. + */ + private function visitPackageManagerChangeLog(): void { + $mink = $this->getMink(); + $assert_session = $mink->assertSession(); + $page = $mink->getSession()->getPage(); + + $this->visit('/admin/reports/dblog'); + $assert_session->statusCodeEquals(200); + $page->selectFieldOption('Type', 'package_manager_change_log'); + $page->pressButton('Filter'); + $assert_session->statusCodeEquals(200); + } + + /** + * Asserts changes requested during the stage life cycle were logged. + * + * This method specifically asserts changes that were *requested* (i.e., + * during the require phase) rather than changes that were actually applied. + * The requested and applied changes may be exactly the same, or they may + * differ (for example, if a secondary dependency was added or updated in the + * stage directory). + * + * @param string[] $expected_requested_changes + * The expected requested changes. + * + * @see ::assertAppliedChangesWereLogged() + * @see \Drupal\package_manager\EventSubscriber\ChangeLogger + */ + protected function assertRequestedChangesWereLogged(array $expected_requested_changes): void { + $this->visitPackageManagerChangeLog(); + $assert_session = $this->getMink()->assertSession(); + + $assert_session->elementExists('css', 'a[href*="/admin/reports/dblog/event/"]:contains("Requested changes:")') + ->click(); + array_walk($expected_requested_changes, $assert_session->pageTextContains(...)); + } + + /** + * Asserts that changes applied during the stage life cycle were logged. + * + * This method specifically asserts changes that were *applied*, rather than + * the changes that were merely requested. For example, if a package was + * required into the stage and it added a secondary dependency, that change + * will be considered one of the applied changes, not a requested change. + * + * @param string[] $expected_applied_changes + * The expected applied changes. + * + * @see ::assertRequestedChangesWereLogged() + * @see \Drupal\package_manager\EventSubscriber\ChangeLogger + */ + protected function assertAppliedChangesWereLogged(array $expected_applied_changes): void { + $this->visitPackageManagerChangeLog(); + $assert_session = $this->getMink()->assertSession(); + + $assert_session->elementExists('css', 'a[href*="/admin/reports/dblog/event/"]:contains("Applied changes:")') + ->click(); + array_walk($expected_applied_changes, $assert_session->pageTextContains(...)); + } + /** * Gets a /package-manager-test-api response. * diff --git a/package_manager/tests/src/Kernel/ChangeLoggerTest.php b/package_manager/tests/src/Kernel/ChangeLoggerTest.php new file mode 100644 index 0000000000..f4e4c8f5c2 --- /dev/null +++ b/package_manager/tests/src/Kernel/ChangeLoggerTest.php @@ -0,0 +1,102 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\EventSubscriber\ChangeLogger; +use Psr\Log\LogLevel; + +/** + * @covers \Drupal\package_manager\EventSubscriber\ChangeLogger + * @group package_manager + */ +class ChangeLoggerTest extends PackageManagerKernelTestBase { + + /** + * The logger to which change lists will be written. + * + * @var \ColinODell\PsrTestLogger\TestLogger + */ + private TestLogger $logger; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + $this->logger = new TestLogger(); + parent::setUp(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + parent::register($container); + + $container->getDefinition(ChangeLogger::class) + ->setMethodCalls([ + ['setLogger', [$this->logger]], + ]); + } + + /** + * Tests that the requested and applied changes are logged. + */ + public function testChangeLogging(): void { + $this->setReleaseMetadata([ + 'semver_test' => __DIR__ . '/../../fixtures/release-history/semver_test.1.1.xml', + ]); + + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => 'package/removed', + 'type' => 'library', + ]) + ->commitChanges(); + + $this->getStageFixtureManipulator() + ->setCorePackageVersion('9.8.1') + ->addPackage([ + 'name' => 'drupal/semver_test', + 'type' => 'drupal-module', + 'version' => '8.1.1', + ]) + ->removePackage('package/removed'); + + $stage = $this->createStage(); + $stage->create(); + $stage->require([ + 'drupal/semver_test:*', + 'drupal/core-recommended:^9.8.1', + ]); + // Nothing should be logged until post-apply. + $stage->apply(); + $this->assertEmpty($this->logger->records); + $stage->postApply(); + + $this->assertTrue($this->logger->hasInfoRecords()); + $records = $this->logger->recordsByLevel[LogLevel::INFO]; + $this->assertCount(2, $records); + // The first record should be of the requested changes. + $expected_message = <<<END +Requested changes: +- Update drupal/core-recommended from 9.8.0 to ^9.8.1 +- Install drupal/semver_test * (any version) +END; + $this->assertSame($expected_message, (string) $records[0]['message']); + + // The second record should be of the actual changes. + $expected_message = <<<END +Applied changes: +- Updated drupal/core from 9.8.0 to 9.8.1 +- Updated drupal/core-dev from 9.8.0 to 9.8.1 +- Updated drupal/core-recommended from 9.8.0 to 9.8.1 +- Installed drupal/semver_test 8.1.1 +- Uninstalled package/removed +END; + $this->assertSame($expected_message, (string) $records[1]['message']); + } + +} diff --git a/package_manager/tests/src/Kernel/StageBaseTest.php b/package_manager/tests/src/Kernel/StageBaseTest.php index 1b89680321..a1547f03d9 100644 --- a/package_manager/tests/src/Kernel/StageBaseTest.php +++ b/package_manager/tests/src/Kernel/StageBaseTest.php @@ -56,6 +56,43 @@ class StageBaseTest extends PackageManagerKernelTestBase { $container->getDefinition('event_dispatcher')->addTag('persist'); } + /** + * @covers ::getMetadata + * @covers ::setMetadata + */ + public function testMetadata(): void { + $stage = $this->createStage(); + $stage->create(); + $this->assertNull($stage->getMetadata('new_key')); + $stage->setMetadata('new_key', 'value'); + $this->assertSame('value', $stage->getMetadata('new_key')); + $stage->destroy(); + + // Ensure that metadata associated with the previous stage was deleted. + $stage = $this->createStage(); + $stage->create(); + $this->assertNull($stage->getMetadata('new_key')); + $stage->destroy(); + + // Ensure metadata cannot be get or set unless the stage has been claimed. + $stage = $this->createStage(); + try { + $stage->getMetadata('new_key'); + $this->fail('Expected an ownership exception, but none was thrown.'); + } + catch (\LogicException $e) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $e->getMessage()); + } + + try { + $stage->setMetadata('new_key', 'value'); + $this->fail('Expected an ownership exception, but none was thrown.'); + } + catch (\LogicException $e) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $e->getMessage()); + } + } + /** * @covers ::getStageDirectory */ diff --git a/tests/src/Build/CoreUpdateTest.php b/tests/src/Build/CoreUpdateTest.php index 8bffd57934..f0f203273c 100644 --- a/tests/src/Build/CoreUpdateTest.php +++ b/tests/src/Build/CoreUpdateTest.php @@ -139,6 +139,16 @@ class CoreUpdateTest extends UpdateTestBase { $this->assertStringContainsString("const VERSION = '9.8.1';", $file_contents['web/core/lib/Drupal.php']); $this->assertUpdateSuccessful('9.8.1'); + + $this->assertRequestedChangesWereLogged([ + 'Update drupal/core-dev from 9.8.0 to 9.8.1', + 'Update drupal/core-recommended from 9.8.0 to 9.8.1', + ]); + $this->assertAppliedChangesWereLogged([ + 'Updated drupal/core from 9.8.0 to 9.8.1', + 'Updated drupal/core-dev from 9.8.0 to 9.8.1', + 'Updated drupal/core-recommended from 9.8.0 to 9.8.1', + ]); } /** @@ -160,6 +170,15 @@ class CoreUpdateTest extends UpdateTestBase { $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); $this->assertExpectedStageEventsFired(UpdateStage::class); $this->assertUpdateSuccessful('9.8.1'); + $this->assertRequestedChangesWereLogged([ + 'Update drupal/core-dev from 9.8.0 to 9.8.1', + 'Update drupal/core-recommended from 9.8.0 to 9.8.1', + ]); + $this->assertAppliedChangesWereLogged([ + 'Updated drupal/core from 9.8.0 to 9.8.1', + 'Updated drupal/core-dev from 9.8.0 to 9.8.1', + 'Updated drupal/core-recommended from 9.8.0 to 9.8.1', + ]); } /** diff --git a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php index c4765f78e2..23d1cfac65 100644 --- a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php +++ b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php @@ -5,7 +5,6 @@ declare(strict_types = 1); namespace Drupal\Tests\automatic_updates\Kernel; use Drupal\automatic_updates\CronUpdateStage; -use Drupal\automatic_updates\UpdateStage; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait; use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; @@ -84,7 +83,6 @@ abstract class AutomaticUpdatesKernelTestBase extends PackageManagerKernelTestBa // Use the test-only implementations of the regular and cron update stages. $overrides = [ - 'automatic_updates.update_stage' => TestUpdateStage::class, 'automatic_updates.cron_update_stage' => TestCronUpdateStage::class, ]; foreach ($overrides as $service_id => $class) { @@ -96,20 +94,6 @@ abstract class AutomaticUpdatesKernelTestBase extends PackageManagerKernelTestBa } -/** - * A test-only version of the regular update stage to override internals. - */ -class TestUpdateStage extends UpdateStage { - - /** - * {@inheritdoc} - */ - public function setMetadata(string $key, $data): void { - parent::setMetadata($key, $data); - } - -} - /** * A test-only version of the cron update stage to override and expose internals. */ @@ -124,11 +108,4 @@ class TestCronUpdateStage extends CronUpdateStage { $this->handlePostApply($stage_id, $start_version, $target_version); } - /** - * {@inheritdoc} - */ - public function setMetadata(string $key, $data): void { - parent::setMetadata($key, $data); - } - } -- GitLab