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

Issue #3275323 by tedbow: AU Extensions: Create form workflow for updating projects

parent e8e50522
No related branches found
No related tags found
No related merge requests found
......@@ -18,6 +18,8 @@ automatic_updates_extensions.module_update:
_permission: 'administer software updates'
options:
_admin_route: TRUE
_maintenance_access: TRUE
_automatic_updates_readiness_messages: skip
automatic_updates_extension.theme_update:
path: '/admin/appearance/automatic-update-extensions'
......@@ -28,3 +30,17 @@ automatic_updates_extension.theme_update:
_permission: 'administer software updates'
options:
_admin_route: TRUE
_maintenance_access: TRUE
_automatic_updates_readiness_messages: skip
automatic_updates_extension.confirmation_page:
path: '/admin/automatic-update-extensions-ready/{stage_id}'
defaults:
_form: '\Drupal\automatic_updates_extensions\Form\UpdateReady'
_title: 'Ready to update'
requirements:
_permission: 'administer software updates'
options:
_admin_route: TRUE
_maintenance_access: TRUE
_automatic_updates_readiness_messages: skip
<?php
namespace Drupal\automatic_updates_extensions;
use Drupal\Core\Url;
use Drupal\package_manager\Exception\StageValidationException;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* A batch processor for updates.
*/
class BatchProcessor {
/**
* The session key under which the stage ID is stored.
*
* @var string
*/
public const STAGE_ID_SESSION_KEY = '_automatic_updates_extensions_stage_id';
/**
* Gets the updater service.
*
* @return \Drupal\automatic_updates_extensions\ExtensionUpdater
* The updater service.
*/
protected static function getUpdater(): ExtensionUpdater {
return \Drupal::service('automatic_updates_extensions.updater');
}
/**
* Records messages from a throwable, then re-throws it.
*
* @param \Throwable $error
* The caught exception.
* @param array $context
* The current context of the batch job.
*
* @throws \Throwable
* The caught exception, which will always be re-thrown once its messages
* have been recorded.
*/
protected static function handleException(\Throwable $error, array &$context): void {
$error_messages = [
$error->getMessage(),
];
if ($error instanceof StageValidationException) {
foreach ($error->getResults() as $result) {
$messages = $result->getMessages();
if (count($messages) > 1) {
array_unshift($messages, $result->getSummary());
}
$error_messages = array_merge($error_messages, $messages);
}
}
foreach ($error_messages as $error_message) {
$context['results']['errors'][] = $error_message;
}
throw $error;
}
/**
* Calls the updater's begin() method.
*
* @param string[] $project_versions
* The project versions to be staged in the update, keyed by package name.
* @param array $context
* The current context of the batch job.
*
* @see \Drupal\automatic_updates_extensions\ExtensionUpdater::begin()
*/
public static function begin(array $project_versions, array &$context): void {
try {
$stage_id = static::getUpdater()->begin($project_versions);
\Drupal::service('session')->set(static::STAGE_ID_SESSION_KEY, $stage_id);
}
catch (\Throwable $e) {
static::handleException($e, $context);
}
}
/**
* Calls the updater's stageVersions() method.
*
* @param array $context
* The current context of the batch job.
*
* @see \Drupal\automatic_updates\Updater::stage()
*/
public static function stage(array &$context): void {
try {
$stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY);
static::getUpdater()->claim($stage_id)->stage();
}
catch (\Throwable $e) {
static::clean($stage_id, $context);
static::handleException($e, $context);
}
}
/**
* Calls the updater's commit() method.
*
* @param string $stage_id
* The stage ID.
* @param array $context
* The current context of the batch job.
*
* @see \Drupal\automatic_updates\Updater::apply()
*/
public static function commit(string $stage_id, array &$context): void {
try {
static::getUpdater()->claim($stage_id)->apply();
}
catch (\Throwable $e) {
static::handleException($e, $context);
}
}
/**
* Calls the updater's clean() method.
*
* @param string $stage_id
* The stage ID.
* @param array $context
* The current context of the batch job.
*
* @see \Drupal\automatic_updates\Updater::clean()
*/
public static function clean(string $stage_id, array &$context): void {
try {
static::getUpdater()->claim($stage_id)->destroy();
}
catch (\Throwable $e) {
static::handleException($e, $context);
}
}
/**
* Finishes the stage batch job.
*
* @param bool $success
* Indicate that the batch API tasks were all completed successfully.
* @param array $results
* An array of all the results.
* @param array $operations
* A list of the operations that had not been completed by the batch API.
*/
public static function finishStage(bool $success, array $results, array $operations): ?RedirectResponse {
if ($success) {
$stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY);
$url = Url::fromRoute('automatic_updates_extension.confirmation_page', [
'stage_id' => $stage_id,
]);
return new RedirectResponse($url->setAbsolute()->toString());
}
static::handleBatchError($results);
return NULL;
}
/**
* Finishes the commit batch job.
*
* @param bool $success
* Indicate that the batch API tasks were all completed successfully.
* @param array $results
* An array of all the results.
* @param array $operations
* A list of the operations that had not been completed by the batch API.
*/
public static function finishCommit(bool $success, array $results, array $operations): ?RedirectResponse {
\Drupal::service('session')->remove(static::STAGE_ID_SESSION_KEY);
if ($success) {
$url = Url::fromRoute('automatic_updates.finish')
->setAbsolute()
->toString();
return new RedirectResponse($url);
}
static::handleBatchError($results);
return NULL;
}
/**
* Handles a batch job that finished with errors.
*
* @param array $results
* The batch results.
*/
protected static function handleBatchError(array $results): void {
if (isset($results['errors'])) {
foreach ($results['errors'] as $error) {
\Drupal::messenger()->addError($error);
}
}
else {
\Drupal::messenger()->addError("Update error");
}
}
}
<?php
namespace Drupal\automatic_updates_extensions\Form;
use Drupal\automatic_updates_extensions\BatchProcessor;
use Drupal\automatic_updates\BatchProcessor as AutoUpdatesBatchProcessor;
use Drupal\automatic_updates_extensions\ExtensionUpdater;
use Drupal\Core\Batch\BatchBuilder;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\State\StateInterface;
use Drupal\package_manager\Exception\StageException;
use Drupal\package_manager\Exception\StageOwnershipException;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines a form to commit staged updates.
*
* @internal
* Form classes are internal.
*/
class UpdateReady extends FormBase {
/**
* The updater service.
*
* @var \Drupal\automatic_updates_extensions\ExtensionUpdater
*/
protected $updater;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* The module list service.
*
* @var \Drupal\Core\Extension\ModuleExtensionList
*/
protected $moduleList;
/**
* Constructs a new UpdateReady object.
*
* @param \Drupal\automatic_updates_extensions\ExtensionUpdater $updater
* The updater service.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Drupal\Core\Extension\ModuleExtensionList $module_list
* The module list service.
*/
public function __construct(ExtensionUpdater $updater, MessengerInterface $messenger, StateInterface $state, ModuleExtensionList $module_list) {
$this->updater = $updater;
$this->setMessenger($messenger);
$this->state = $state;
$this->moduleList = $module_list;
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'automatic_updates_update_ready_form';
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('automatic_updates_extensions.updater'),
$container->get('messenger'),
$container->get('state'),
$container->get('extension.list.module'),
);
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, string $stage_id = NULL) {
try {
$this->updater->claim($stage_id);
}
catch (StageOwnershipException $e) {
$this->messenger()->addError($this->t('Cannot continue the update because another Composer operation is currently in progress.'));
return $form;
}
$messages = [];
// @todo Add logic to warn about possible new database updates. Determine if
// \Drupal\automatic_updates\Validator\StagedDatabaseUpdateValidator
// should be duplicated or changed so that it can work with other stages.
// @see \Drupal\automatic_updates\Validator\StagedDatabaseUpdateValidator
// @see \Drupal\automatic_updates\Form\UpdateReady::buildForm()
// Don't set any messages if the form has been submitted, because we don't
// want them to be set during form submit.
if (!$form_state->getUserInput()) {
foreach ($messages as $type => $messages_of_type) {
foreach ($messages_of_type as $message) {
$this->messenger()->addMessage($message, $type);
}
}
}
$form['actions'] = [
'cancel' => [
'#type' => 'submit',
'#value' => $this->t('Cancel update'),
'#submit' => ['::cancel'],
],
'#type' => 'actions',
];
$form['stage_id'] = [
'#type' => 'value',
'#value' => $stage_id,
];
// @todo Display the project versions that will be update including any
// dependencies that are Drupal projects.
$form['backup'] = [
'#prefix' => '<strong>',
'#markup' => $this->t('Back up your database and site before you continue. <a href=":backup_url">Learn how</a>.', [':backup_url' => 'https://www.drupal.org/node/22281']),
'#suffix' => '</strong>',
];
$form['maintenance_mode'] = [
'#title' => $this->t('Perform updates with site in maintenance mode (strongly recommended)'),
'#type' => 'checkbox',
'#default_value' => TRUE,
];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Continue'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Store maintenance_mode setting so we can restore it when done.
$this->getRequest()
->getSession()
->set(AutoUpdatesBatchProcessor::MAINTENANCE_MODE_SESSION_KEY, $this->state->get('system.maintenance_mode'));
if ($form_state->getValue('maintenance_mode')) {
$this->state->set('system.maintenance_mode', TRUE);
}
$stage_id = $form_state->getValue('stage_id');
$batch = (new BatchBuilder())
->setTitle($this->t('Apply updates'))
->setInitMessage($this->t('Preparing to apply updates'))
->addOperation([BatchProcessor::class, 'commit'], [$stage_id])
->addOperation([BatchProcessor::class, 'clean'], [$stage_id])
->setFinishCallback([BatchProcessor::class, 'finishCommit'])
->toArray();
batch_set($batch);
}
/**
* Cancels the in-progress update.
*/
public function cancel(array &$form, FormStateInterface $form_state): void {
try {
$this->updater->destroy();
$this->messenger()->addStatus($this->t('The update was successfully cancelled.'));
$form_state->setRedirect('automatic_updates_extensions.report_update');
}
catch (StageException $e) {
$this->messenger()->addError($e->getMessage());
}
}
}
......@@ -4,7 +4,9 @@ namespace Drupal\automatic_updates_extensions\Form;
use Drupal\automatic_updates\Event\ReadinessCheckEvent;
use Drupal\automatic_updates\Validation\ReadinessTrait;
use Drupal\automatic_updates_extensions\BatchProcessor;
use Drupal\automatic_updates_extensions\ExtensionUpdater;
use Drupal\Core\Batch\BatchBuilder;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\system\SystemManager;
......@@ -174,7 +176,18 @@ class UpdaterForm extends FormBase {
$selected_projects = array_filter($projects);
$recommended_versions = $form_state->getValue('recommended_versions');
$selected_versions = array_intersect_key($recommended_versions, $selected_projects);
$this->messenger()->addMessage(print_r($selected_versions, TRUE));
$batch = (new BatchBuilder())
->setTitle($this->t('Downloading updates'))
->setInitMessage($this->t('Preparing to download updates'))
->addOperation(
[BatchProcessor::class, 'begin'],
[$selected_versions]
)
->addOperation([BatchProcessor::class, 'stage'])
->setFinishCallback([BatchProcessor::class, 'finishStage'])
->toArray();
batch_set($batch);
}
/**
......
......@@ -3,6 +3,7 @@
namespace Drupal\Tests\automatic_updates_extensions\Build;
use Drupal\Tests\automatic_updates\Build\UpdateTestBase;
use Drupal\Tests\automatic_updates_extensions\Traits\FormTestTrait;
/**
* Tests updating modules in a staging area.
......@@ -11,11 +12,13 @@ use Drupal\Tests\automatic_updates\Build\UpdateTestBase;
*/
class ModuleUpdateTest extends UpdateTestBase {
use FormTestTrait;
/**
* Tests updating a module in a staging area.
* {@inheritdoc}
*/
public function testApi(): void {
$this->createTestProject('RecommendedProject');
protected function createTestProject(string $template): void {
parent::createTestProject($template);
$this->setReleaseMetadata([
'drupal' => __DIR__ . '/../../../../tests/fixtures/release-history/drupal.9.8.1-security.xml',
'alpha' => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml',
......@@ -32,11 +35,19 @@ END;
$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->assertModuleVersion('alpha', '1.0.0');
$this->installModules(['automatic_updates_extensions_test_api', 'alpha']);
// Change both modules' upstream version.
$this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.1.0');
}
/**
* Tests updating a module in a staging area via the API.
*/
public function testApi(): void {
$this->createTestProject('RecommendedProject');
// 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
......@@ -62,4 +73,43 @@ END;
$this->assertSame('1.1.0', $module_composer_json->version);
}
/**
* Tests updating a module in a staging area via the UI.
*/
public function testUi() {
$this->createTestProject('RecommendedProject');
$mink = $this->getMink();
$session = $mink->getSession();
$page = $session->getPage();
$assert_session = $mink->assertSession();
$this->visit('/admin/reports/updates');
$page->clickLink('Update Extensions');
$this->assertUpdateTableRow($assert_session, 'Alpha', '1.0.0', '1.1.0');
$page->checkField('projects[alpha]');
$page->pressButton('Update');
$this->waitForBatchJob();
$assert_session->pageTextContains('Ready to update');
$page->pressButton('Continue');
$this->waitForBatchJob();
$assert_session->pageTextContains('Update complete!');
$this->assertModuleVersion('alpha', '1.1.0');
}
/**
* Asserts a module is a specified version.
*
* @param string $module_name
* The module name.
* @param string $version
* The expected version.
*/
private function assertModuleVersion(string $module_name, string $version) {
$web_root = $this->getWebRoot();
$composer_json = file_get_contents("$web_root/modules/contrib/$module_name/composer.json");
$data = json_decode($composer_json, TRUE);
$this->assertSame($version, $data['version']);
}
}
......@@ -4,9 +4,12 @@ namespace Drupal\Tests\automatic_updates_extensions\Functional;
use Drupal\automatic_updates\Event\ReadinessCheckEvent;
use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\ValidationResult;
use Drupal\Tests\automatic_updates\Functional\AutomaticUpdatesFunctionalTestBase;
use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait;
use Drupal\Tests\automatic_updates_extensions\Traits\FormTestTrait;
use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
/**
* Tests updating using the form.
......@@ -16,6 +19,8 @@ use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait;
class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
use ValidationTestTrait;
use FormTestTrait;
use PackageManagerBypassTestTrait;
/**
* {@inheritdoc}
......@@ -39,6 +44,19 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
*/
protected $updateProject = 'semver_test';
/**
* Data provider for testSuccessfulUpdate().
*
* @return bool[]
* The test cases.
*/
public function providerMaintanceMode() {
return [
'maintiance_mode_on' => [TRUE],
'maintiance_mode_off' => [FALSE],
];
}
/**
* {@inheritdoc}
*/
......@@ -81,11 +99,44 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
* Asserts the table shows the updates.
*/
private function assertTableShowsUpdates() {
$assert = $this->assertSession();
$assert->elementTextContains('css', '.update-recommended td:nth-of-type(2)', 'Semver Test');
$assert->elementTextContains('css', '.update-recommended td:nth-of-type(3)', '8.1.0');
$assert->elementTextContains('css', '.update-recommended td:nth-of-type(4)', '8.1.1');
$assert->elementsCount('css', '.update-recommended tbody tr', 1);
$this->assertUpdateTableRow($this->assertSession(), 'Semver Test', '8.1.0', '8.1.1');
}
/**
* Tests an update that has no errors or special conditions.
*
* @param bool $maintenance_mode_on
* Whether maintenance should be on at the beginning of the update.
*
* @dataProvider providerMaintanceMode
*/
public function testSuccessfulUpdate(bool $maintenance_mode_on): void {
$this->setProjectInstalledVersion('8.1.0');
$this->checkForUpdates();
$state = $this->container->get('state');
$state->set('system.maintenance_mode', $maintenance_mode_on);
$page = $this->getSession()->getPage();
// Navigate to the automatic updates form.
$this->drupalGet('/admin/reports/updates');
$this->clickLink('Update Extensions');
$this->assertTableShowsUpdates();
$page->checkField('projects[' . $this->updateProject . ']');
$page->pressButton('Update');
$this->checkForMetaRefresh();
$this->assertUpdateStagedTimes(1);
// Confirm that the site was put into maintenance mode if needed.
$this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on);
$page->pressButton('Continue');
$this->checkForMetaRefresh();
$assert_session = $this->assertSession();
$assert_session->addressEquals('/admin/reports/updates');
// Confirm that the site was in maintenance before the update was applied.
// @see \Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber::handleEvent()
$this->assertTrue($state->get(PreApplyEvent::class . '.system.maintenance_mode'));
$assert_session->pageTextContainsOnce('Update complete!');
// Confirm the site was returned to the original maintenance mode state.
$this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on);
}
/**
......
<?php
namespace Drupal\Tests\automatic_updates_extensions\Traits;
use Behat\Mink\WebAssert;
/**
* Common methods for testing the update form.
*/
trait FormTestTrait {
/**
* Asserts the table shows the updates.
*
* @param \Behat\Mink\WebAssert $assert
* The web assert tool.
* @param string $expected_project_title
* The expected project title.
* @param string $expected_installed_version
* The expected installed version.
* @param string $expected_update_version
* The expected update version.
*/
private function assertUpdateTableRow(WebAssert $assert, string $expected_project_title, string $expected_installed_version, string $expected_update_version): void {
$assert->elementTextContains('css', '.update-recommended td:nth-of-type(2)', $expected_project_title);
$assert->elementTextContains('css', '.update-recommended td:nth-of-type(3)', $expected_installed_version);
$assert->elementTextContains('css', '.update-recommended td:nth-of-type(4)', $expected_update_version);
$assert->elementsCount('css', '.update-recommended tbody tr', 1);
}
}
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