Skip to content
Snippets Groups Projects
Commit bf9874f6 authored by Ted Bowman's avatar Ted Bowman
Browse files

Issue #3273364 by tedbow: Implement Extension Updater logic

parent 5ac879de
No related branches found
No related tags found
1 merge request!249Issue #3273364: Implement Extension Updater logic
Showing
with 406 additions and 1 deletion
......@@ -4,4 +4,3 @@ description: 'Allows updates to themes and modules'
core_version_requirement: ^9.2
dependencies:
- drupal:automatic_updates
hidden: true
......@@ -2,10 +2,94 @@
namespace Drupal\automatic_updates_extensions;
use Drupal\automatic_updates\Exception\UpdateException;
use Drupal\package_manager\Event\StageEvent;
use Drupal\package_manager\Exception\StageValidationException;
use Drupal\package_manager\Stage;
/**
* Defines a service to perform updates for modules and themes.
*/
class ExtensionUpdater extends Stage {
/**
* Begins the update.
*
* @param string[] $project_versions
* The versions of the packages to update to, keyed by package name.
*
* @return string
* The unique ID of the stage.
*
* @throws \InvalidArgumentException
* Thrown if no project version for Drupal core is provided.
*/
public function begin(array $project_versions): string {
$composer = $this->getActiveComposer();
$package_versions = [
'production' => [],
'dev' => [],
];
$require_dev = $composer->getComposer()
->getPackage()
->getDevRequires();
foreach ($project_versions as $project_name => $version) {
$package = "drupal/$project_name";
$group = array_key_exists($package, $require_dev) ? 'dev' : 'production';
$package_versions[$group][$package] = $version;
}
// Ensure that package versions are available to pre-create event
// subscribers. We can't use ::setMetadata() here because it requires the
// stage to be claimed, but that only happens during ::create().
$this->tempStore->set(static::TEMPSTORE_METADATA_KEY, [
'packages' => $package_versions,
]);
return parent::create();
}
/**
* Returns the package versions that will be required during the update.
*
* @return string[][]
* An array with two sub-arrays: 'production' and 'dev'. Each is a set of
* package versions, where the keys are package names and the values are
* version constraints understood by Composer.
*/
public function getPackageVersions(): array {
return $this->getMetadata('packages');
}
/**
* Stages the update.
*/
public function stage(): void {
$this->checkOwnership();
// Convert an associative array of package versions, keyed by name, to
// command-line arguments in the form `vendor/name:version`.
$map = function (array $versions): array {
$requirements = [];
foreach ($versions as $package => $version) {
$requirements[] = "$package:$version";
}
return $requirements;
};
$versions = array_map($map, $this->getPackageVersions());
$this->require($versions['production'], $versions['dev']);
}
/**
* {@inheritdoc}
*/
protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
try {
parent::dispatch($event, $on_error);
}
catch (StageValidationException $e) {
throw new UpdateException($e->getResults(), $e->getMessage() ?: "Unable to complete the update because of errors.", $e->getCode(), $e);
}
}
}
{
"require": {
"drupal/core-recommended": "^9",
"drupal/my_module": "^9"
},
"require-dev": {
"drupal/my_dev_module": "^9"
}
}
{
"packages": [
{
"name": "drupal/core-recommended",
"version": "9.8.0",
"require": {
"drupal/core": "9.8.0"
}
},
{
"name": "drupal/my_module",
"version": "9.8.0"
}
],
"packages-dev": [
{
"name": "drupal/my_dev_module",
"version": "9.8.1"
}
]
}
{
"packages": [
{
"name": "drupal/core-recommended",
"version": "9.8.0",
"require": {
"drupal/core": "9.8.0"
}
},
{
"name": "drupal/core",
"version": "9.8.0"
},
{
"name": "drupal/my_module",
"version": "9.8.0"
},
{
"name": "drupal/my_dev_module",
"version": "9.8.1"
}
],
"dev": true,
"dev-package-names": [
"drupal/my_dev_module"
]
}
name: 'Automatic Updates Extensions Test API'
description: 'Provides API endpoints for doing stage operations in functional tests.'
type: module
package: Testing
dependencies:
- automatic_updates:automatic_updates_extensions
automatic_updates_extensions_test_api:
path: '/automatic-updates-extensions-test-api'
defaults:
_controller: 'Drupal\automatic_updates_extensions_test_api\ApiController::run'
requirements:
_access: 'TRUE'
<?php
namespace Drupal\automatic_updates_extensions_test_api;
use Drupal\automatic_updates_extensions\ExtensionUpdater;
use Drupal\Core\Controller\ControllerBase;
use Drupal\package_manager\PathLocator;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Provides API endpoints to interact with a staging area in functional tests.
*/
class ApiController extends ControllerBase {
/**
* The extension updater.
*
* @var \Drupal\automatic_updates_extensions\ExtensionUpdater
*/
private $extensionUpdater;
/**
* The path locator service.
*
* @var \Drupal\package_manager\PathLocator
*/
private $pathLocator;
/**
* Constructs an ApiController object.
*
* @param \Drupal\automatic_updates_extensions\ExtensionUpdater $extensionUpdater
* The updater.
* @param \Drupal\package_manager\PathLocator $path_locator
* The path locator service.
*/
public function __construct(ExtensionUpdater $extensionUpdater, PathLocator $path_locator) {
$this->extensionUpdater = $extensionUpdater;
$this->pathLocator = $path_locator;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('automatic_updates_extensions.updater'),
$container->get('package_manager.path_locator')
);
}
/**
* Runs a complete stage life cycle.
*
* Creates a staging area, requires packages into it, applies changes to the
* active directory, and destroys the stage.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request. The runtime and dev dependencies are expected to be in
* either the query string or request body, under the 'runtime' and 'dev'
* keys, respectively. There may also be a 'files_to_return' key, 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 run(Request $request): JsonResponse {
$this->extensionUpdater->begin($request->get('projects', []));
$this->extensionUpdater->stage();
$this->extensionUpdater->apply();
$this->extensionUpdater->destroy();
$dir = $this->pathLocator->getProjectRoot();
$file_contents = [];
foreach ($request->get('files_to_return', []) as $path) {
$file_contents[$path] = file_get_contents($dir . '/' . $path);
}
return new JsonResponse($file_contents);
}
}
<?php
namespace Drupal\Tests\automatic_updates_extensions\Build;
use Drupal\Tests\package_manager\Build\TemplateProjectTestBase;
/**
* Tests updating modules in a staging area.
*
* @group automatic_updates_extensions
*/
class ModuleUpdateTest extends TemplateProjectTestBase {
/**
* Tests updating a module in a staging area.
*/
public function testApi(): void {
$this->createTestProject('RecommendedProject');
$this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.0.0');
$this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha --update-with-all-dependencies', 'project');
$this->installQuickStart('minimal');
$this->formLogin($this->adminUsername, $this->adminPassword);
$this->installModules(['automatic_updates_extensions_test_api']);
// Change both modules' upstream version.
$this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.1.0');
// Use the API endpoint to create a stage and update the 'alpha' module to
// 1.1.0. We ask the API to return the contents of the module's
// composer.json file, so we can assert that they were updated to the
// version we expect.
// @see \Drupal\automatic_updates_extensions_test_api\ApiController::run()
$query = http_build_query([
'projects' => [
'alpha' => '1.1.0',
],
'files_to_return' => [
'web/modules/contrib/alpha/composer.json',
],
]);
$this->visit("/automatic-updates-extensions-test-api?$query");
$mink = $this->getMink();
$mink->assertSession()->statusCodeEquals(200);
$file_contents = $mink->getSession()->getPage()->getContent();
$file_contents = json_decode($file_contents, TRUE);
$module_composer_json = json_decode($file_contents['web/modules/contrib/alpha/composer.json']);
$this->assertSame('1.1.0', $module_composer_json->version);
}
}
<?php
namespace Drupal\Tests\automatic_updates_extensions\Kernel;
use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
use Drupal\Tests\user\Traits\UserCreationTrait;
/**
* @coversDefaultClass \Drupal\automatic_updates_extensions\ExtensionUpdater
*
* @group automatic_updates_extensions
*/
class ExtensionUpdaterTest extends AutomaticUpdatesKernelTestBase {
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'automatic_updates',
'automatic_updates_test',
'automatic_updates_extensions',
'user',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('user');
}
/**
* Tests that correct versions are staged after calling ::begin().
*/
public function testCorrectVersionsStaged(): void {
// Create a user who will own the stage even after the container is rebuilt.
$user = $this->createUser([], NULL, TRUE, ['uid' => 2]);
$this->setCurrentUser($user);
$fixture_dir = __DIR__ . '/../../fixtures/fake-site';
$locator = $this->mockPathLocator($fixture_dir, $fixture_dir);
$id = $this->container->get('automatic_updates_extensions.updater')->begin([
'my_module' => '9.8.1',
'my_dev_module' => '9.8.2',
]);
// Rebuild the container to ensure the package versions are persisted.
/** @var \Drupal\Core\DrupalKernel $kernel */
$kernel = $this->container->get('kernel');
$kernel->rebuildContainer();
$this->container = $kernel->getContainer();
// Keep using the mocked path locator and current user.
$this->container->set('package_manager.path_locator', $locator);
$this->setCurrentUser($user);
$extension_updater = $this->container->get('automatic_updates_extensions.updater');
// Ensure that the target package versions are what we expect.
$expected_versions = [
'production' => [
'drupal/my_module' => '9.8.1',
],
'dev' => [
'drupal/my_dev_module' => '9.8.2',
],
];
$this->assertSame($expected_versions, $extension_updater->claim($id)->getPackageVersions());
// When we call ExtensionUpdater::stage(), the stored project versions
// should be read from state and passed to Composer Stager's Stager service,
// in the form of a Composer command. This is done using
// package_manager_bypass's invocation recorder, rather than a regular mock,
// in order to test that the invocation recorder itself works. The
// production requirements are changed first, followed by the dev
// requirements. Then the installed packages are updated. This is tested
// functionally in Package Manager.
// @see \Drupal\Tests\package_manager\Build\StagedUpdateTest
$expected_arguments = [
[
'require',
'--no-update',
'drupal/my_module:9.8.1',
],
[
'require',
'--dev',
'--no-update',
'drupal/my_dev_module:9.8.2',
],
[
'update',
'--with-all-dependencies',
'drupal/my_module:9.8.1',
'drupal/my_dev_module:9.8.2',
],
];
$extension_updater->stage();
$actual_arguments = $this->container->get('package_manager.stager')
->getInvocationArguments();
$this->assertSame(count($expected_arguments), count($actual_arguments));
foreach ($actual_arguments as $i => [$arguments]) {
$this->assertSame($expected_arguments[$i], $arguments);
}
}
}
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