Issue #3220627: Optionally apply configuration changes automatically

This commit is contained in:
Michael Stenta 2021-09-23 10:05:49 -04:00
commit bc7af7bd47
21 changed files with 621 additions and 0 deletions

View File

@ -1,5 +1,7 @@
# Automated updates
## Update hooks
farmOS modules may change and evolve over time. If these changes require
updates to a farmOS database or configuration, then update logic should be
provided so that users of the module can perform the necessary changes
@ -9,3 +11,79 @@ This logic can be supplied via implementations of `hook_update_N()`.
For more information, see the documentation for Drupal's
[Update API](https://www.drupal.org/docs/drupal-apis/update-api/).
## Configuration updates
If the farmOS Update module is enabled, changes to configuration will be
automatically reverted when caches are rebuilt. The purpose of this is to
make it easier for farmOS module developers to make minor changes to the
configuration included with their module without writing update hooks.
Note that this only handles overridden configuration. It does not handle
missing, inactive, or added configuration.
In most cases this is desirable, but if you are intentionally overriding
configuration in your farmOS instance then you have a few options for
disabling this behavior.
### Disable farmOS Update module
The easiest way to disable automatic configuration updates is to turn off the
`farm_update` module. This can be done via Drush:
drush pm-uninstall farm_update
This will completely disable automatic reverts of configuration. You can then
manage all configuration changes and deployment manually. One way to do this
is with the `config_update_ui` module, which provides a report of all missing,
inactive, added, and changed configuration. This can be enabled via Drush:
drush en config_update_ui
Then go to `/admin/config/development/configuration/report/type/system.all` to
see the full report. Individual configuration items can be reverted, imported,
and deleted.
### Exclude specific configuration
Alternatively, if you want to keep automatic updates enabled, but want control
over certain items, the farmOS Update module provides two mechanisms for
excluding specific configuration from automatic updates.
#### `hook_farm_update_exclude_config()`
If a module overrides certain configuration items, either in
`hook_install()` or via something like the `config_rewrite` module, the
module can list these configuration items in an array returned by an
implementation of `hook_farm_update_exclude_config()`.
For example, in `mymodule.module`:
```php
/**
* Implements hook_farm_update_exclude_config().
*/
function mymodule_farm_update_exclude_config() {
// Exclude mymodule.settings from automatic configuration updates.
return [
'mymodule.settings',
];
}
```
#### `farm_update.settings`
The farmOS Update module will also check the `exclude_config` setting in
its own `farm_update.settings` configuration for a list of configuration
items to exclude from automatic updates. This can be provided by a custom
module in `config/install/farm_update.settings.yml`, or synced/imported into
active configuration by other means.
For example, in `farm_update.settings.yml`:
```yaml
exclude_config:
# Exclude mymodule.settings from automatic configuration updates.
- mymodule.settings
```

View File

@ -22,6 +22,11 @@ patches when they become available.
your browser and follow the steps to run automated updates. It is important
to do this before using the new version of farmOS to ensure that any
necessary changes to the database or configuration are made.
4. **Clear caches.** farmOS caches can be cleared by going to
`https://[hostname]/admin/config/development/performance` in your browser
and clicking "Clear all caches", or via the command line with Drush:
`drush cr`. Cache clearing is only necessary if no updates are performed
during `update.php`, otherwise they will be cleared automatically.
### Updating via Docker

View File

@ -23,6 +23,7 @@ function farm_modules() {
'farm_login' => t('Login with username or email.'),
'farm_settings' => t('farmOS Settings forms'),
'farm_ui' => t('farmOS UI'),
'farm_update' => t('farmOS Update'),
],
'default' => [
'farm_land' => t('Land assets'),
@ -70,3 +71,15 @@ function farm_form_update_manager_update_form_alter(&$form, &$form_state, $form_
\Drupal::messenger()->addError($message);
$form['actions']['#access'] = FALSE;
}
/**
* Implements hook_farm_update_exclude_config().
*/
function farm_farm_update_exclude_config() {
// Exclude config that we have overridden in hook_install().
return [
'system.file',
'user.settings',
];
}

View File

@ -105,3 +105,16 @@ function farm_api_entity_base_field_info(EntityTypeInterface $entity_type) {
return $fields;
}
/**
* Implements hook_farm_update_exclude_config().
*/
function farm_api_farm_update_exclude_config() {
// Exclude config that we have overridden in hook_install().
return [
'jsonapi.settings',
'jsonapi_extras.settings',
'simple_oauth.settings',
];
}

View File

@ -342,3 +342,14 @@ function farm_entity_form_alter(&$form, FormStateInterface $form_state, $form_id
// Disable access to the revision checkbox.
$form['revision']['#access'] = FALSE;
}
/**
* Implements hook_farm_update_exclude_config().
*/
function farm_entity_farm_update_exclude_config() {
// Exclude config that we have overridden in hook_install().
return [
'entity_reference_integrity_enforce.settings',
];
}

View File

@ -215,3 +215,14 @@ function template_preprocess_quantity(array &$variables) {
}
}
}
/**
* Implements hook_farm_update_exclude_config().
*/
function quantity_farm_update_exclude_config() {
// Exclude quantity.settings config from automatic updates.
return [
'quantity.settings',
];
}

View File

@ -31,3 +31,14 @@ function farm_ui_location_help($route_name, RouteMatchInterface $route_match) {
return $output;
}
/**
* Implements hook_farm_update_exclude_config().
*/
function farm_ui_location_farm_update_exclude_config() {
// Exclude config that we have overridden in hook_install().
return [
'inspire_tree.settings',
];
}

View File

@ -177,3 +177,16 @@ function farm_ui_theme_farm_ui_theme_region_items(string $entity_type) {
return [];
}
}
/**
* Implements hook_farm_update_exclude_config().
*/
function farm_ui_theme_farm_update_exclude_config() {
// Exclude config that we have overridden in hook_install().
return [
'block.block.gin_local_actions',
'gin.settings',
'system.theme',
];
}

View File

@ -0,0 +1,10 @@
farm_update.settings:
type: config_object
label: 'farmOS Update settings'
mapping:
exclude_config:
type: sequence
label: 'Configuration items excluded from automatic updates'
sequence:
type: string
label: 'Configuration item'

View File

@ -0,0 +1,6 @@
services:
farm_update.commands:
class: Drupal\farm_update\Commands\FarmUpdateCommands
arguments: [ '@farm.update']
tags:
- { name: drush.command }

View File

@ -0,0 +1,32 @@
<?php
/**
* @file
* Hooks provided by farm_update.
*
* This file contains no working PHP code; it exists to provide additional
* documentation for doxygen as well as to document hooks in the standard
* Drupal manner.
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Specify config items that should be excluded from automatic updates.
*
* @return array
* An array of config item names.
*/
function hook_farm_update_exclude_config() {
return [
'core.extension',
'system.date',
];
}
/**
* @} End of "addtogroup hooks".
*/

View File

@ -0,0 +1,7 @@
name: farmOS Update
description: Automatically update farmOS configuration. Turn this off if configuration is managed by other means.
type: module
package: farmOS
core_version_requirement: ^9
dependencies:
- config_update:config_update

View File

@ -0,0 +1,30 @@
<?php
/**
* @file
* Hooks and customizations for the farm_update module.
*/
/**
* Implements hook_rebuild().
*/
function farm_update_rebuild() {
\Drupal::service('farm.update')->rebuild();
}
/**
* Implements hook_farm_update_exclude_config().
*/
function farm_update_farm_update_exclude_config() {
// Exclude Drupal core configurations from automatic updates.
return [
'core.extension',
'system.date',
'system.performance',
'system.site',
'update.settings',
'user.role.anonymous',
'user.role.authenticated',
];
}

View File

@ -0,0 +1,4 @@
services:
farm.update:
class: Drupal\farm_update\FarmUpdate
arguments: [ '@module_handler', '@entity_type.manager', '@config_update.config_diff', '@config_update.config_list', '@config_update.config_update' ]

View File

@ -0,0 +1,44 @@
<?php
namespace Drupal\farm_update\Commands;
use Drupal\farm_update\FarmUpdateInterface;
use Drush\Commands\DrushCommands;
/**
* Farm Update Drush commands.
*
* @ingroup farm
*/
class FarmUpdateCommands extends DrushCommands {
/**
* Farm update service.
*
* @var \Drupal\farm_update\FarmUpdateInterface
*/
protected $farmUpdate;
/**
* FarmUpdateCommands constructor.
*
* @param \Drupal\farm_update\FarmUpdateInterface $farm_update
* Farm update service.
*/
public function __construct(FarmUpdateInterface $farm_update) {
parent::__construct();
$this->farmUpdate = $farm_update;
}
/**
* Rebuild farmOS configuration.
*
* @command farm_update:rebuild
*
* @usage farm_update:rebuild
*/
public function rebuild() {
$this->farmUpdate->rebuild();
}
}

View File

@ -0,0 +1,204 @@
<?php
namespace Drupal\farm_update;
use Drupal\config_update\ConfigDiffer;
use Drupal\config_update\ConfigListerWithProviders;
use Drupal\config_update\ConfigReverter;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
/**
* Farm update service.
*/
class FarmUpdate implements FarmUpdateInterface {
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The entity manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityManager;
/**
* The config differ.
*
* @var \Drupal\config_update\ConfigDiffer
*/
protected $configDiff;
/**
* The config lister.
*
* @var \Drupal\config_update\ConfigListerWithProviders
*/
protected $configList;
/**
* The config reverter.
*
* @var \Drupal\config_update\ConfigReverter
*/
protected $configUpdate;
/**
* Constructs a FarmUpdate object.
*
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_manager
* The entity type manager.
* @param \Drupal\config_update\ConfigDiffer $config_diff
* The config differ.
* @param \Drupal\config_update\ConfigListerWithProviders $config_list
* The config lister.
* @param \Drupal\config_update\ConfigReverter $config_update
* The config reverter.
*/
public function __construct(ModuleHandlerInterface $module_handler, EntityTypeManagerInterface $entity_manager, ConfigDiffer $config_diff, ConfigListerWithProviders $config_list, ConfigReverter $config_update) {
$this->moduleHandler = $module_handler;
$this->entityManager = $entity_manager;
$this->configDiff = $config_diff;
$this->configList = $config_list;
$this->configUpdate = $config_update;
}
/**
* {@inheritdoc}
*/
public function rebuild(): void {
// Get a list of excluded config.
$exclude_config = $this->getExcludedItems();
// Build a list of config to revert, without excluded config.
$revert_config = array_diff($this->getDifferentItems('type', 'system.all'), $exclude_config);
// Iterate through config items and revert them.
foreach ($revert_config as $name) {
// Get the config type.
// The lister gives NULL if simple configuration, but the reverter
// expects 'system.simple' so we convert it.
$type = $this->configList->getTypeNameByConfigName($name);
if ($type === NULL) {
$type = 'system.simple';
}
// Get the config short name.
$shortname = $this->getConfigShortname($type, $name);
// Perform the operation.
$result = $this->configUpdate->revert($type, $shortname);
// Log failures.
if (!$result) {
\Drupal::logger('farm_update')->error('Failed to revert config: @config', ['@config' => $name]);
}
}
}
/**
* Lists excluded config items.
*
* Lists config items that should be excluded from all automatic updates.
*
* @return array
* An array of config item names.
*/
protected function getExcludedItems() {
// Ask modules for config exclusions.
$exclude_config = $this->moduleHandler->invokeAll('farm_update_exclude_config');
// Load farm_update.settings to get additional exclusions.
$settings_exclude_config = \Drupal::config('farm_update.settings')->get('exclude_config');
if (!empty($settings_exclude_config)) {
$exclude_config = array_merge($exclude_config, $settings_exclude_config);
}
// Always exclude this module's configuration.
// This isn't strictly necessary because we don't provide default config
// in config/install. But in the unlikely event that a custom module does
// provide this config, and then it is somehow overridden by another means,
// it would be reverted. So we exclude it here just to be extra safe.
$exclude_config[] = 'farm_update.settings';
return $exclude_config;
}
/**
* Lists differing config items.
*
* Lists config items that differ from the versions provided by your
* installed modules, themes, or install profile. See config-diff to show
* what the differences are.
*
* This method is copied directly from ConfigUpdateUiCliService.
*
* @param string $type
* Run the report for: module, theme, profile, or "type" for config entity
* type.
* @param string $name
* The machine name of the module, theme, etc. to report on. See
* config-list-types to list types for config entities; you can also use
* system.all for all types, or system.simple for simple config.
*
* @return array
* An array of differing configuration items.
*
* @see \Drupal\config_update_ui\ConfigUpdateUiCliService::getDifferentItems()
*/
protected function getDifferentItems($type, $name) {
[$activeList, $installList, $optionalList] = $this->configList->listConfig($type, $name);
$addedItems = array_diff($activeList, $installList, $optionalList);
$activeAndAddedItems = array_diff($activeList, $addedItems);
$differentItems = [];
foreach ($activeAndAddedItems as $name) {
$active = $this->configUpdate->getFromActive('', $name);
$extension = $this->configUpdate->getFromExtension('', $name);
if (!$this->configDiff->same($active, $extension)) {
$differentItems[] = $name;
}
}
sort($differentItems);
return $differentItems;
}
/**
* Gets the config item shortname given the type and name.
*
* This method is copied directly from ConfigUpdateUiCliService.
*
* @param string $type
* The type of the config item.
* @param string $name
* The name of the config item.
*
* @return string
* The shortname for the configuration item.
*
* @see \Drupal\config_update_ui\ConfigUpdateUiCliService::getConfigShortname()
*/
protected function getConfigShortname($type, $name) {
$shortname = $name;
if ($type != 'system.simple') {
$definition = $this->entityManager->getDefinition($type);
$prefix = $definition->getConfigPrefix() . '.';
if (strpos($name, $prefix) === 0) {
$shortname = substr($name, strlen($prefix));
}
}
return $shortname;
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace Drupal\farm_update;
/**
* Farm update service interface.
*/
interface FarmUpdateInterface {
/**
* Rebuild farmOS configuration.
*/
public function rebuild(): void;
}

View File

@ -0,0 +1,2 @@
exclude_config:
- system.theme

View File

@ -0,0 +1,7 @@
name: farmOS Update Tests
description: 'Support module for farmOS Update testing.'
type: module
package: Testing
core_version_requirement: ^9
dependencies:
- farm:farm_update

View File

@ -0,0 +1,13 @@
<?php
/**
* @file
* Contains farm_update_test.module.
*/
/**
* Implements hook_farm_update_exclude_config().
*/
function farm_update_test_farm_update_exclude_config() {
return ['system.file'];
}

View File

@ -0,0 +1,92 @@
<?php
namespace Drupal\Tests\farm_update\Kernel;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests for farmOS Update module.
*
* @group farm
*/
class FarmUpdateTest extends KernelTestBase {
/**
* The config factory service.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The farm update service.
*
* @var \Drupal\farm_update\FarmUpdateInterface
*/
protected $farmUpdate;
/**
* {@inheritdoc}
*/
protected static $modules = [
'config_update',
'farm_update',
'farm_update_test',
'system',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->configFactory = \Drupal::service('config.factory');
$this->farmUpdate = \Drupal::service('farm.update');
$this->installConfig([
'farm_update_test',
'system',
]);
}
/**
* Test farmOS Update module.
*/
public function testFarmUpdate() {
// Confirm that overridden config gets reverted.
$this->farmUpdateTestRevertSetting('system.logging', 'error_level', 'all');
// Confirm that config excluded via hook_farm_update_exclude_config() does
// not get reverted.
$this->farmUpdateTestRevertSetting('system.file', 'default_scheme', 'public', TRUE);
// Confirm that config excluded via farm_update.settings does not get
// reverted.
$this->farmUpdateTestRevertSetting('system.theme', 'default', 'claro', TRUE);
}
/**
* Helper method to test reverting a setting.
*
* @param string $config
* Configuration name.
* @param string $setting
* Setting name within the configuration.
* @param string $override
* Value to use for override.
* @param bool $excluded
* Whether or not we expect this config to be excluded. Defaults to FALSE.
* If set to TRUE, then we expect that the config will still be overridden
* after rebuild.
*/
protected function farmUpdateTestRevertSetting(string $config, string $setting, string $override, bool $excluded = FALSE) {
$original = \Drupal::config($config)->get($setting);
$this->configFactory->getEditable($config)->set($setting, $override)->save();
$this->assertEquals($override, \Drupal::config($config)->get($setting), 'Setting is overridden before rebuild.');
$this->farmUpdate->rebuild();
$expected_value = $excluded ? $override : $original;
$expected_message = $excluded ? 'Setting is overridden after rebuild.' : 'Setting is reverted after rebuild.';
$this->assertEquals($expected_value, \Drupal::config($config)->get($setting), $expected_message);
}
}