Problem/Motivation
Upgrading a Composer plugin can be an error-prone operation. If the Plugin class instantiates objects (or, more specifically, loads classes) in the `activate` method (or any pre-command hook), and those classes are updated as part of a `composer update` operation, then the old version of the code loaded in `activate` will continue to be used in the post-update hooks, whereas in contrast, classes loaded after the update will be loaded from the new code. This can cause old versions of the classes that were pre-loaded attempt to use methods that no longer exist, e.g. if things are removed. If new versions of the classes have references back to the older (pre-loaded) classes, then errors could be thrown if methods that did not previously exist are called.
Proposed resolution
In the Scaffold plugin, avoid instantiating objects in the `activate` hook, and add a test that confirms whether an upgrade from the Scaffold plugin version 8.8.0 to the current code under test (e.g. in the current patch) works.
The ProjectMessage plugin is already written in a way that makes it resilient against changes. Consider opening a follow-on issue to add a test protecting ProjectMessage from future changes that might alter this.
The VendorHardening plugin has both pre and post hooks, so it would be difficult to protect it by lazy-loading objects. There are not many objects in this plugin, though, so it might be possible to protect it by pre-loading all classes in `activate`. The objects that exist have a simple API, though, so it is less likely that they will need to change. Consider opening a follow-on issue to add a test protecting VendorHardening from future changes that might alter this.
Remaining tasks
Make a follow-on issue to continue this work once #3086644: LegacyProject composer templates wrongly reference 8.x + fix test coverage lands.
DONE: #3104985: Increase scope of Composer plugin update tests to cover other plugins
User interface changes
None.
API changes
None.
Data model changes
None.
Release notes snippet
The Core Composer Scaffold plugin was modified to be more defensive about how it handled object creation. Previously, the Handler object was being created during the 'activate' hook (called as soon as the Scaffold plugin is initialized); this patch postpones the creation of this class until the post-update hook. Composer plugins are vulnerable to potential fragility during updates if internal methods and fields are used in certain ways. For example, if the Handler object is created before the 'composer update' runs, by the time the methods in Handler are called, the implementation of the classes it uses may have changed in incompatible ways. This can potentially translate to upgrade problems for the end user even long after the fix is made, e.g. if someone upgrades straight from 8.8.1 to 9.0.0 (depending on what changes are made to the plugin's implementation). A functional test was added to detect this sort of failure.
| Comment | File | Size | Author |
|---|---|---|---|
| #29 | 3104922_28.patch | 8.13 KB | mile23 |
| #16 | 3104922-16.patch | 8.13 KB | greg.1.anderson |
| #16 | 9-to-16-interdiff.txt | 616 bytes | greg.1.anderson |
| #9 | 3104922-9.patch | 7.95 KB | greg.1.anderson |
| #9 | 6-to-9-interdiff.txt | 690 bytes | greg.1.anderson |
Comments
Comment #2
greg.1.anderson commentedHere is a patch that helps to guard the Scaffold plugin from future upgrades, and also adds a test to confirm that upgrading won't break.
Comment #3
greg.1.anderson commentedRunning tests is cool.
Comment #4
greg.1.anderson commented#2 was missing the new test file, so here is a new patch that contains it.
No interdiff, because the only difference between #2 and #4 is the addition of core/tests/Drupal/Tests/Composer/Plugin/Scaffold/Functional/ScaffoldUpgradeTest.php
Comment #5
mile23+1 on the need to do this, and also the urgency.
Review:
So I guess we need to keep a handler object around so it can keep track of whether a beforeRequire thing happened. Otherwise I'd suggest just making a new handler for each event.
Handler::beforeRequire() doesn't actually need the event object.
Do you mean to run update, or are you saying this catches the use case for both install and update?
I think these comments are reversed.
We could assert against the expected error message if it didn't work correctly, maybe with assertNotRegExp().
Comment #6
greg.1.anderson commented1. It would be possible to not cache the handler object. However, it's helpful to keep it as a general technique, and furthermore, throwing away the handler object will not save us from upgrade errors, if there are any. The first time a handler object is created, the Handler.php source file will be included. PHP won't allow us to load the same file more than once, so even if we throw away and recreate the handler, the new handler will still be using the old version of the source code.
2. True. I was concerned about changing the Handler API, as I did not want to introduce a breaking change (since the old versions of the Scaffold plugin instantiate a Handler object early). However, in this case it is possible to change this part of the Handler API without breaking the old Scaffold tool, so I went ahead and did it. The test confirm that this internal API change works.
3. The intention of this call to
composer installis just to set up the scaffold plugin before we downgrade and then upgrade it again. We are still testing to see that the Scaffold plugin is working here, but this is more of a control than a test. Updated the comment for clarity.4. The comments are in the correct order. `composer config --unset repositories.packagist.org` removes the
"packagist.org": falsefrom the composer.json file. Removing this configuration allows us to use Packagist again. Similarly, when we no longer want to use Packagist, we re-insert thefalseconfiguration.5. If the operation really didn't work, then the exec will return a non-zero status code, and the test will fail. If the SUT is not being re-installed, then the "Symlinking from" line will definitely be missing. There are many possible messages that will NOT appear when the operation works correctly; I'm not sure that it is necessary to try to enumerate them with a NotRegExp. I'll add any that you think are particularly important, though.
Comment #7
mile23Cool. +1 on the changes. The tests haven't finished, but RTBC from me.
Comment #8
greg.1.anderson commentedOne coding standard message -- fix for that coming right up.
Comment #9
greg.1.anderson commentedUpdated patch.
Comment #10
greg.1.anderson commentedComment #11
mile23So the follow-up in 'Remaining Tasks' would be to move responsibilities out of the plugin class to avoid breaking plugins during update.
Not as urgent, because those plugins don't have the potential to break your scaffolded files.
Comment #12
greg.1.anderson commentedThe plugin class is always instantiated by Composer before any part of the plugin runs (activate in the plugin class is the first thing that runs). Once the plugin class is loaded, it won't change, even if the rest of the plugin code is updated. Because of this dynamic, sometimes the solution is to move more responsibilities into the plugin class.
The main weakness is going to be the contract between the plugin class any any other class that it instantiates before a "post" event (e.g. post-update). Any method of any such class called by the plugin class must be frozen forever. If it is not, users will have to know to run
composer update--no-plugins when they upgrade that plugin from the older version to any new version with a new API.When we analyze the other plugins by this criteria, we see that:
The ProjectMessage plugin only has "post" events, and does not instantiate any objects in its activate method. Unless this changes, this plugin is going to be safe to update. This is also a simple plugin, so it should not have a great need to change in a way that would make future updates dangerous.
The VendorHardening plugin has both "pre" and "post" events. It would be really difficult to avoid instantiating any objects until the "post" events are called. However, the VendorHardening plugin also has very few classes: just a file security class, and a config class. As long as the API for these classes never change, the VendorHardening plugin should also be safe to update moving forward.
The remaining task, then, should be to make a follow-on issue to convert the test here to a build test so that we can have upgrade tests for all of the plugins, to avoid the possibility that someone might unwittingly make an unsafe change to some plugin.
Comment #13
greg.1.anderson commentedComment #14
alexpottIf the
$this->handlerobject has been instantiated already should we be calling$this->handler->requireWasCalled()or should we doing$this->handler = NULL. Either is what we want. Maybe if the handler is instantiated at this point should we be throwing an error?Comment #15
greg.1.anderson commentedWe are expecting that the handler should never be instantiated at this point.
$this->handler = NULLwould not help; if the handler was instantiated, then the Handler class would already have been autoloaded, which is unrecoverable.I'll throw an Error here to catch early instantiation of the handler object.
Comment #16
greg.1.anderson commentedNew patch that throws an error when expectations are not met.
Comment #17
mile23Re: #14: I don't see how we'd end up with a stray handler object in onCommand(), but if there was one, it would mean that something was off the rails enough to warrant an error. That's because the scaffolding might be a security update, and we'd rather break your build than let you miss the security update.
Comment #18
greg.1.anderson commentedThanks for the review @mile23. Throwing an error in onCommand will hopefully cause things to "break on the bench" and never get to the point of being committed or released to anyone; at least, that's the theory.
Comment #19
alexpottCommitted and pushed 61fa1bf27c to 9.0.x and a4b6c45a36 to 8.9.x. Thanks!
Comment #22
greg.1.anderson commentedWoot! Thanks everyone!
Comment #23
alexpottThis is more of a bug. I'm going to ask release managers about backport to 8.8.x
Comment #24
greg.1.anderson commentedMy explanation from a slack convo:
Thanks for asking about the backport @alexpott.
Comment #25
catchIf it was runtime code I'd probably say no, but the reasoning in #24 is good, the less versions the unpatched version are in the better.
Comment #26
xjmFor the future, even if it does not seem that a release note will be needed, it's helpful to write one anyway. This helps us make decisions about the potential disruptions of issues and things like policy-exception backports as well.
Comment #27
greg.1.anderson commentedUpdated IS per #26.
Comment #28
mile23This is a straight copy of @greg.1.anderson's patch in #16, which still applies to 8.8.x.
Comment #29
mile23Maybe I should add the patch. :-)
Comment #30
mile23I RTBCd it before (in #17), and it's the same patch. And it's not really mine since it's a copy of #16.
Comment #31
xjmThanks for adding the release note.
Regarding the end of it:
Is this the only change we made to fix their applications? Assuming it's not. What else did we do to fix it and protect them in the future?
Comment #32
mile23We also have #3104985: Increase scope of Composer plugin update tests to cover other plugins to test other Composer plugins in a similar way.
Beyond that, I'm not sure what else there is to do (or to say in release notes)... The test performs a scaffold using Drupal 8.8.0, and then updates the scaffold to HEAD/patch and checks that it worked.
Comment #33
greg.1.anderson commentedUpdated the release note snippet in the IS to be more specific about the exact change that was made here.
Comment #34
catchThis fell off the radar. Since we are approaching the end of regular bug releases in 8.8, I think we should leave it in 8.9 at this point.
Comment #35
greg.1.anderson commentedThis will slightly increase the window where we will need to be extremely cautious about changes made to the scaffold plugin. This is probably okay, though, as we have tests that will catch problems. It will just have to be a limitation we live with for a while.