Problem/Motivation

As a site administrator, I want to exclude certain modules from deployments, in order to not put developer tools on my production environment.
While #3048860: Create Config Environment API and the other issues around the Configuration Environment module aim to substitute Config Split, by making environments config entities and exporting their configuration as collections, it is sometimes just simpler not to share the development modules configuration and instead making sure that development modules are guaranteed from being excluded from the configuration export.

Proposed resolution

Add Config Exclude to core.
This unblocks #2991683: Move configuration transformation API in \Drupal\Core\Config namespace

Remaining tasks

Add the functionality to Config Environment transitionally and move it to core.services in #2991683: Move configuration transformation API in \Drupal\Core\Config namespace or in #3079029: Move module config exclusion from config_environment to core.services

User interface changes

none

API changes

New setting for modules that are excluded from the configuration export and remain installed on import that doesn't have them.

$settings['config_exclude_modules'] = ['devel', 'stage_file_proxy'];

Data model changes

none

Release notes snippet

A new setting allows development modules to be excluded from the configuration synchronisation. See the change notice for more details.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

bircher created an issue. See original summary.

bircher’s picture

Status: Active » Needs review
FileSize
8.17 KB

Attached is a patch porting all of config_exclude to the new transformer API.

I added it to config_environment because creating a new module requires adding a drupal.org help page and we can do that once the decision is taken that it should be its own module in core.

bircher’s picture

I just realized I forgot the collections..

borisson_’s picture

Status: Needs review » Needs work

I added it to config_environment because creating a new module requires adding a drupal.org help page and we can do that once the decision is taken that it should be its own module in core.

I think that's a good decision, they are closely tied together so keeping them in one module seems like the easiest solution.

The patch also seems to introduce a few cs violations, those should also be fixed,

  1. +++ b/core/modules/config_environment/src/EventSubscriber/EnvironmentModulesEventSubscriber.php
    @@ -0,0 +1,168 @@
    + * The event subscriber preventing development modules to be exported.
    

    not sure if we should mention development modules here. They can be other modules as well, so maybe configured modules?

  2. +++ b/core/modules/config_environment/src/EventSubscriber/EnvironmentModulesEventSubscriber.php
    @@ -0,0 +1,168 @@
    +  private $active;
    

    We don't usually add private variables so that we extend classes, but since this is final it seems to be ok.

  3. +++ b/core/modules/config_environment/src/EventSubscriber/EnvironmentModulesEventSubscriber.php
    @@ -0,0 +1,168 @@
    +    $this->active = $active;
    

    Maybe this should be activeStorage instead of just active? Because at first it seemed like this property was a boolean flag.

  4. +++ b/core/modules/config_environment/src/EventSubscriber/EnvironmentModulesEventSubscriber.php
    @@ -0,0 +1,168 @@
    +   * Make sure excluded modules are not uninstalled by adding them and their
    

    here it is excluded modules, lets use that language everywhere.

  5. +++ b/core/modules/config_environment/src/EventSubscriber/EnvironmentModulesEventSubscriber.php
    @@ -0,0 +1,168 @@
    +      // If the core.extension config is not present there is nothing to do.
    +      // This means that probably the storage is empty or non-functional.
    ...
    +      // If the core.extension config is not present there is nothing to do.
    +      // This means some other process has rendered it non-functional already.
    

    This comment should probably be the same.

bircher’s picture

Thanks for the review!
RE #4:
1) well we will have to add to the change record that this is not meant for a big amount of modules and should not be used when one wants to stage and deploy all configuration and know that what you stage is deployed. since excluded modules by design will not be uninstalled when the config doesn't include them. I changed it also with regard to 4)

2) yes it is final and private as this is not meant to be extended. We can always make it non final without breaking anything if at some point someone wants to extend it. I think we should do that more in general.

3) sure, makes sense.

4) excluded modules makes sense yes.

5) Well one could argue that it should be the same, but an export storage can never be empty unless it has been made empty which means non-functional in this case by some other event subscriber. The import on the other hand can be empty simply when the folder is empty. I am not against changing any of the comments as such if they are helpful for others.

bircher’s picture

Status: Needs work » Needs review
borisson_’s picture

Issue tags: +Needs change record

Awesome, thank you for the clear response, now all this needs is the Change Record. The code already looks great.

marcvangend’s picture

Status: Needs review » Needs work

Thanks for your work on this. As the author of the Config Exclude module I would obviously like to see functionality like this in core :-)

At first I wasn't sure if this class should be final, but after seeing this talk by Wim Leers (thanks Wim!) and reading #3019332: Use final to define classes that are NOT extension points I agree.

Some remarks about the code:

  1. +++ b/core/modules/config_environment/config_environment.services.yml
    @@ -11,3 +11,9 @@ services:
    +  # config_environment services.
    

    I'm not sure what this comment means, or what it tries to clarify here.

  2. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,169 @@
    +  public function __construct(StorageInterface $active, Settings $settings, ConfigManagerInterface $manager) {
    +    $this->activeStorage = $active;
    

    For consistency, I would prefer $active_storage instead of $active here as well.

  3. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,169 @@
    +   * Get the modules set as excluded in the drupal settings.
    +   *
    +   * @return string[]
    +   */
    

    Coding standards: This needs a description of the returned value, eg. "An array of module names". Also, "Drupal" deserves a capital "D".

  4. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,169 @@
    +    return $this->settings->get("config_exclude_modules", []);
    

    Should the name of this setting be namespaced, eg. "config_environment_excluded_modules"?

  5. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,169 @@
    +   * @return string[]
    +   */
    

    Coding standards: This needs a description of the returned value, eg "An array of configuration names".

I will now go test and try to break the code :-)

marcvangend’s picture

OK, I did some manual testing.

Steps performed on a standard-profile D8 site:
- Enable config_environment module.
- Composer-require and enable Devel module.
- Add $settings['config_exclude_modules'] = ['devel']; to settings.php.
- Export config: full export through the admin UI, full export with Drush 9, single-item export of core.extension.yml.
- Check the result: no traces of Devel module should be present.
- Import the exported config again.
- Check the result: although the exported config does not mention Devel module, it should still be enabled.

Results:
- The ExcludedModulesEventSubscriber only kicks in during a full export in the admin UI, not during a single-item export or Drush export. Maybe this was to be expected given the current state of Drupal core and/or Drush? At least as a developer, I was expecting it to work with drush cex too.
- The config export generated in the admin UI indeed does not contain traces of Devel module; not in core.extension.yml, nor Devel's own config files.
- When importing the config .tar.gz archive again (in the admin UI), Devel module remains enabled even though it is not in core.extension.yml.

marcvangend’s picture

Status: Needs work » Needs review
FileSize
9.75 KB
1.73 KB

Here's a patch that fixes points 2, 3 and 5 from comment #8.
You can ignore point 1, I now see it is a response to the @todo higher up in the file.
Point is 4 open for discussion afaic.

bircher’s picture

Issue summary: View changes

RE #9
Yes for drush to work you need: https://github.com/drush-ops/drush/pull/4061
The single item import and export are not doing a configuration synchronisation so they are not going to hide excluded modules but rather reflect the active configuration storage.
So your experiment reflects that this is working as expected.

#8 4) Well, I took the name config_exclude uses, if we want to add this to core as config_exclude then that would make sense as is.

#4 and the issue summary remaining task:
If we were to add this to a new module then it could immediately become stable. As a followup we can also add some messages on the status page or the config import/export pages in case we want to (config_exclude currently doesn't do that as far as I know)
If we are to put it in config_environment then we will mark it as experimental-beta-stable after this so that it will stay in 8.8 as experimental and we add the environment config entities (#3048860: Create Config Environment API) and all the rest we had planned in #3033427: [plan] Add support for environment-specific configuration with update hooks (which will not be a problem of course).

In any case this issue unblocks #2991683: Move configuration transformation API in \Drupal\Core\Config namespace

marcvangend’s picture

Thanks for the explanation Bircher. I'm happy my tests confirm it is working as expected.

Indeed if the final goal is to turn this into a separate module, the setting name doesn't need to be changed.

I like the idea to notify users about excluded config on the import/export page. (And during drush cim / cex too, I suppose.)

So we need a Change Record, and perhaps a re-roll to split this off into a separate module, but code-wise I would call this RTBC.

pandaski’s picture

Shall this module itself be excluded or can be conditionally excluded based on the different environments?

bircher’s picture

RE #12 yes whether to split it off in a different module or not is ultimately a decision above my pay-grade and to be honest I can see good arguments for both and I would be happy either way.
After all if you do not set this setting then nothing happens. (Do we need a test for that too?)

RE #13 I would argue that you don't have to do that, but you could of course. Maybe I would not recommend it because it will allow for a more nuanced environment control once we have the other parts and that will not work well when the environment configuration is excluded from the configuration synchronisation. (So if we put it into its own module then you can safely exclude the config_exclude module, if it stays as part of config_environment then you really shouldn't)

This leads me to another observation I made this morning while drinking my coffee: Since the list of excluded modules is in settings.php it is totally up to the user to make sure it contains sane values. For example there is nothing stopping you from excluding the system or user module, yes you can then no longer import the configuration unless you had also excluded the system or user modules on the target site and you can no longer install from that configuration. But nothing stops you from manually editing the config yamls and that can also lead to not being able to import the config.
But what I dread a bit more is that some people will think that this is a great idea for multi site which it is NOT. But that is a whole other discussion we need to have and is totally off topic here.
I guess the question is should we do something about this (like a list of modules that can never be excluded)?
I am of the opinion that we should document properly that using this feature means that you opt out of some of the consistency guarantees of the configuration synchronisation process and not actively prevent you from doing what you want to be doing.

It is maybe also important to note that however you configure this it will never break your running site, it might just not allow you to import configuration. This happens in exactly the same way as if you had manually messed up the configuration files.

marcvangend’s picture

Status: Needs review » Needs work

Re #13: I'm not sure why you would want to exclude config_exclude itself, but it is possible. I ran a quick test and found that when config_exclude is listed in $settings['config_exclude_modules'], it remains enabled and functional when you import config that does not list Config Export (actually, config_environment with the latest patch) in core.extension.yml. In other words, you could enable it on your local development environment, together with other excluded modules, without any effect on the git repo you commit your config to.

While doing that quick test I did find a minor bug: When you change $settings['config_exclude_modules'] without making any other configuration changes, it does not take effect. This happens because a change to $settings['config_exclude_modules'] does not trigger one of the ConfigEvent events, so ExportStorageManager::onConfigChange() is not called and $this->state->set(self::NEEDS_REBUILD_KEY, TRUE); is not executed. Back to "needs work"!

bircher’s picture

Right, I was thinking about this "caching" of the export storage and how it could be a problem when we put the api in core. But then we didn't really have a need for it and it would have made it more complex. So now I added a new event that is dispatched when the storage manager isn't sure that it needs to rebuild the storage. That addition can then be used to check if the settings have changed.

Status: Needs review » Needs work

The last submitted patch, 16: 3077504-16-test-only.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

bircher’s picture

Status: Needs work » Needs review
FileSize
18.55 KB

Ah I never know how to name patches or in which order to upload them so that the bot don't kick them back...

bircher’s picture

actually some more coding standards fixes..

alexpott’s picture

+++ b/core/modules/config_environment/src/Core/Config/ExportStorageManager.php
@@ -83,7 +83,12 @@ public function __construct(StorageInterface $active, StateInterface $state, Con
+    if (!$rebuild) {
+      // @todo: Use ConfigEvents::STORAGE_EXPORT_REBUILD in #2991683
+      $rebuild = $this->eventDispatcher->dispatch('config.export.rebuild')->isPropagationStopped();
+    }

Let's not use event propagation as a signal here. Let's add an event with methods like setRebuildNeeded() and isRebuildNeeded().

bircher’s picture

Yes that makes it easier to reason about it.

alexpott’s picture

  1. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,201 @@
    +    if ($this->state->get("config_exclude_modules") != $this->getExcludedModules()) {
    

    I thought about namespacing this key a bit - something like config.exclude_modules - but the current value is nice because it matches the settings.php key. That said there's no reason we can't make this string a class constant and use it for state and settings.

  2. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,201 @@
    +    if (!$storage->exists('core.extension')) {
    +      // If the core.extension config is not present there is nothing to do.
    +      // This means that probably the storage is empty or non-functional.
    +      return;
    +    }
    

    Does this happen?

  3. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,201 @@
    +    $modules = $this->settings->get("config_exclude_modules", []);
    +    if (!is_array($modules)) {
    +      return [];
    +    }
    

    Why the is array check - there's the default value - it seems overly cautious.

  4. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,201 @@
    +  private function getConfigNames() {
    

    I think this could be better named. Something like getDependentConfigNames()

  5. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,201 @@
    +    foreach ($this->manager->findConfigEntityDependents('config', array_unique($config)) as $dependent) {
    +      $config[] = $dependent->getConfigDependencyName();
    +    }
    

    Maybe a comment above saying that now we're finding any configuration dependent on such the configuration.

    Also I'm deeply suspicious that this is too simplistic looking at \Drupal\Core\Config\ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval() - there are config entities that adapt themselves on module uninstall for example. I've often felt we should not allow a module to be excluded if there are these sort of indirect dependencies.

bircher’s picture

RE #22
1) Sure, I also like that the key is the same in settings and state.

2) Yes, it is empty when the sync folder is empty and even if the event would not be dispatched when empty then we still work with the storage that could have been modified by other event subscribers, so this check is to make sure that if the storage is not in a state we expect then we don't do anything.

3) Well, we use it as an array here so that was just a sort of type check, if you set the settings to a non-array value php would then complain. But we can argue that this is then the users fault for misconfiguring the site.

4) +1

5) more comments are better yes.

And about ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval(), well I don't have enough time to really get into what happens there, but I think ConfigManager::findConfigEntityDependents('config' ...) will find all of the ones in the graph, so with calling \Drupal\Core\Config\Entity\ConfigEntityInterface::onDependencyRemoval some config would qualify not to be excluded in some altered form while we now exclude them wholesale.
So while it is maybe too simplistic, I think we are on the safe side as we make sure that no config is part of the sync directory/zip that depends on a module which is excluded. This is also the algorithm that config_split uses so I don't know if it is something a lot of people encounter.
There are two roads we could go down here that I see:
A) We do more advanced filtering, we modify the configuration that can be made to not depend on the excluded modules and use that to export and we merge the config on import to add back the things that we removed. I think we could do that in a followup if we need to. Ie if there is a use case.
B) We create a concept of "modules that can not be excluded" which contains a hard coded list of system modules and some sort of dynamic check to see what effects the module has on configuration (like languages, third party settings etc) I don't know what conditions that would be or how we would check for them though.. so if that is the road we want to take then someone else needs to take over this issue or at least give a lot of specific input.

bircher’s picture

Issue summary: View changes
Issue tags: -Needs change record

I added the change notice here: https://www.drupal.org/node/3079028

I also had a chat with @alexpott on slack what could be done to verify which modules can be excluded or not.
The system we have to validate which modules can be uninstalled work with the \Drupal\Core\Config\ConfigEvents::IMPORT_VALIDATE event which is dispatched from the ConfigImporter. In order to verify that a module can be excluded would, thus, include creating a ConfigImporter just to dispatch this event. This is not necessary since the import will do that making sure that invalid configuration can not be imported. So excluding modules has the same effect as manually editing the yaml files and the ConfigImporter will assert that required modules can not be uninstalled.

I also created a followup issue to discuss whether the event subscriber we add here should be added to core together with the api or remain as a feature in the experimental module.

borisson_’s picture

Status: Needs review » Reviewed & tested by the community

The code here looks very solid and this is a very useful new feature. I think this is good addition to make the CMI-2 initiative even more better.

I can't find any reason why this shouldn't go in.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,206 @@
    +  const EXCLUDED_MODULES_KEY = "config_exclude_modules";
    ...
    +    return $this->settings->get("config_exclude_modules", []);
    

    Can use the class constant here too.

  2. +++ b/core/modules/config_environment/src/EventSubscriber/ExcludedModulesEventSubscriber.php
    @@ -0,0 +1,206 @@
    +    // Find all configuration that depends on the configuration found above.
    +    foreach ($this->manager->findConfigEntityDependents('config', array_unique($config)) as $dependent) {
    +      $config[] = $dependent->getConfigDependencyName();
    +    }
    

    I'm not sure that there is a test of this situation yet. It looks like only direct dependencies are being tested.

johnwebdev’s picture

Excluded modules and all the configuration that directly and indirectly depends on them are not exported through in the tarball and through tools using the config.storage.export service to export the configuration.
When importing the configuration the excluded modules and their configuration are not removed.
It is possible to exclude required modules, which means that the exported configuration can not be imported any more.
This is an advanced feature and using it means opting out of some of the guarantees the configuration synchronisation provides.

What does a required module mean? A module that another module depends on that is not excluded?

alexpott’s picture

@johndevman both system and user are required modules. I.e they have required: true in their .info.yml files. It might also be possible to do weird things by excluding a module that supplies a field type with data (for example telephone). But in all cases we have config import validation and the ability to tell the user exactly what is going to change on import.

bircher’s picture

Status: Needs work » Needs review
FileSize
23.54 KB
5 KB

RE #25 Thanks!
RE: #26
1) ah I had overlooked that, nice!
2) well that is relatively simple to test, so yea lets do that.

marcvangend’s picture

Status: Needs review » Reviewed & tested by the community

I did some more manual testing, everything works as expected. Great work @bircher and thanks for your input @alexpott! All feedback since #25 has been addressed, so going back to RTBC.

larowlan’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +8.8.0 highlights

Adding review credits.

Reviewed this and the only thing that I think is missing is docs in example.settings.php for the new setting.

Can we get an example entry for this in example.settings.php with some documentation of how to use it etc.

larowlan’s picture

Status: Needs work » Reviewed & tested by the community
Issue tags: +Needs followup

Discussed ^ with @xjm and @bircher

Because this is an experimental module, we don't need to do that now, but we need to do it before the module is marked stable.

So tagging as needs follow-up - can you add a follow-up issue to add those docs to settings.php and add it to the config_environment stable roadmap?

bircher’s picture

  • larowlan committed 4e8f977 on 8.8.x
    Issue #3077504 by bircher, marcvangend, alexpott, borisson_: Add...
larowlan’s picture

Status: Reviewed & tested by the community » Fixed

Committed 4e8f977 and pushed to 8.8.x. Thanks!

Published change record

🥇

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

claudiu.cristea’s picture

bircher’s picture

Issue tags: -8.8.0 highlights

Un-tagging the 8.8.0 highlights since the related #3079029: Move module config exclusion from config_environment to core.services already has the tag. This issue here added the code to the alpha module, the other one made it stable which is the real highlight.