-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
[JENKINS-75232] Prevent dynamic plugin installation from registering the same extension twice in some cases #10240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
70faaaf
24f86ad
6817532
4b33059
acba892
5b702a0
6c25f40
b2e5e15
8af7aec
b4b60cd
d23f8ba
99b8be8
2889db2
f73026b
a7ac415
6bf3b09
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -2867,11 +2867,21 @@ | |||
| delta = ExtensionComponentSet.union(delta, ef.refresh().filtered()); | ||||
| } | ||||
|
|
||||
| List<ExtensionList> listsToFireOnChangeListeners = new ArrayList<>(); | ||||
| for (ExtensionList el : extensionLists.values()) { | ||||
| el.refresh(delta); | ||||
| if (el.refresh(delta)) { | ||||
| listsToFireOnChangeListeners.add(el); | ||||
| } | ||||
| } | ||||
| for (ExtensionList el : descriptorLists.values()) { | ||||
jglick marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
| el.refresh(delta); | ||||
| if (el.refresh(delta)) { | ||||
| listsToFireOnChangeListeners.add(el); | ||||
| } | ||||
| } | ||||
| // Refresh all extension lists before firing any listeners in case a listener would cause any new extension | ||||
| // lists to be forcibly loaded, which may lead to duplicate entries for the same extension object in a list. | ||||
| for (var el : listsToFireOnChangeListeners) { | ||||
| el.fireOnChangeListeners(); | ||||
|
Comment on lines
+2881
to
+2884
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am also happy to consider alternative fixes if anyone has any ideas. One thing I considered was to instead check for duplicates in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate prevention added in 6817532 to fix issues with extensions that load other extensions in their constructors. |
||||
| } | ||||
|
|
||||
| // TODO: we need some generalization here so that extension points can be notified when a refresh happens? | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| package hudson; | ||
|
|
||
| import static org.hamcrest.MatcherAssert.assertThat; | ||
| import static org.hamcrest.Matchers.empty; | ||
| import static org.hamcrest.Matchers.hasSize; | ||
| import static org.junit.Assert.assertNotNull; | ||
|
|
||
| import java.util.Collection; | ||
| import jenkins.plugins.dependee.Dependee; | ||
| import jenkins.plugins.dependee.DependeeExtensionPoint; | ||
| import jenkins.plugins.dynamic_extension_loading.CustomExtensionLoadedViaConstructor; | ||
| import jenkins.plugins.dynamic_extension_loading.CustomExtensionLoadedViaListener; | ||
| import jenkins.plugins.dynamic_extension_loading.CustomPeriodicWork; | ||
| import jenkins.plugins.optional_depender.OptionalDepender; | ||
| import org.junit.Rule; | ||
| import org.junit.Test; | ||
| import org.jvnet.hudson.test.Issue; | ||
| import org.jvnet.hudson.test.JenkinsRule; | ||
| import org.jvnet.hudson.test.RealJenkinsRule; | ||
| import org.jvnet.hudson.test.RealJenkinsRule.SyntheticPlugin; | ||
|
|
||
| public class ExtensionListRjrTest { | ||
| @Rule | ||
| public RealJenkinsRule rjr = new RealJenkinsRule(); | ||
|
|
||
| /** | ||
| * Check that dynamically loading a plugin does not lead to extension lists with duplicate entries. | ||
| * In particular we test extensions that load other extensions in their constructors and extensions that have | ||
| * methods that load other extensions and which are called by an {@link ExtensionListListener}. | ||
| */ | ||
| @Test | ||
| @Issue("JENKINS-75232") | ||
| public void checkDynamicLoad_singleRegistration() throws Throwable { | ||
| var pluginJpi = rjr.createSyntheticPlugin(new SyntheticPlugin(CustomPeriodicWork.class.getPackage()) | ||
| .shortName("dynamic-extension-loading") | ||
| .header("Plugin-Dependencies", "variant:0")); | ||
| var fqcn1 = CustomExtensionLoadedViaListener.class.getName(); | ||
| var fqcn2 = CustomExtensionLoadedViaConstructor.class.getName(); | ||
| rjr.then(r -> { | ||
| r.jenkins.pluginManager.dynamicLoad(pluginJpi); | ||
| assertSingleton(r, fqcn1); | ||
| assertSingleton(r, fqcn2); | ||
| }); | ||
| } | ||
|
|
||
| private static void assertSingleton(JenkinsRule r, String fqcn) throws Exception { | ||
| var clazz = r.jenkins.pluginManager.uberClassLoader.loadClass(fqcn); | ||
| try { | ||
| assertNotNull(ExtensionList.lookupSingleton(clazz)); | ||
| } catch (Throwable t) { | ||
| var list = ExtensionList.lookup(clazz).stream().map(e -> | ||
| // TODO: Objects.toIdentityString in Java 19+ | ||
| e.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(e))).toList(); | ||
| System.out.println("Duplicates are: " + list); | ||
| throw t; | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| @Issue({ "JENKINS-50336", "JENKINS-60449" }) | ||
| public void installDependedOptionalPluginWithoutRestart() throws Throwable { | ||
| var optionalDependerJpi = rjr.createSyntheticPlugin(new SyntheticPlugin(OptionalDepender.class.getPackage()) | ||
| .header("Plugin-Dependencies", "variant:0,dependee:0;resolution:=optional")); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A lot easier to follow the test, not to say edit it! |
||
| var dependeeJpi = rjr.createSyntheticPlugin(new SyntheticPlugin(Dependee.class.getPackage()).shortName("dependee")); | ||
| var fqcn1 = OptionalDepender.class.getName(); | ||
| var fqcn2 = DependeeExtensionPoint.class.getName(); | ||
| rjr.then(r -> { | ||
| // Load optional-depender. | ||
| r.jenkins.pluginManager.dynamicLoad(optionalDependerJpi); | ||
| // JENKINS-60449: Extension depending on dependee class isn't loaded | ||
| assertThat((Collection<?>) r.jenkins.getExtensionList(fqcn1), empty()); | ||
| // Load dependee. | ||
| r.jenkins.pluginManager.dynamicLoad(dependeeJpi); | ||
| // JENKINS-60449: Classes in depender are loaded prior to OptionalDepender being loaded. | ||
| assertThat((Collection<?>) r.jenkins.getExtensionList(fqcn1), hasSize(1)); | ||
| // JENKINS-50336: Extension point from dependee now includes optional extension from optional-depender | ||
| assertThat((Collection<?>) r.jenkins.getExtensionList(fqcn2), hasSize(1)); | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -86,7 +86,6 @@ | |
| import java.util.stream.Collectors; | ||
| import jenkins.ClassLoaderReflectionToolkit; | ||
| import jenkins.RestartRequiredException; | ||
| import jenkins.model.GlobalConfiguration; | ||
| import jenkins.model.Jenkins; | ||
| import net.sf.json.JSONArray; | ||
| import net.sf.json.JSONObject; | ||
|
|
@@ -389,28 +388,6 @@ private String callDependerValue() throws Exception { | |
| assertEquals(1, r.jenkins.getExtensionList("org.jenkinsci.plugins.dependencytest.depender.DependerExtension").size()); | ||
| } | ||
|
|
||
| /** | ||
| * Load "optional-depender" and then load "dependee". | ||
| * Asserts that "depender" can access to "dependee". | ||
| */ | ||
| @Issue("JENKINS-60449") | ||
| @WithPlugin("variant.hpi") | ||
| @Test public void installDependedOptionalPluginWithoutRestart() throws Exception { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved into Really, most of the tests here should probably be migrated to use |
||
| // Load optional-depender. | ||
| { | ||
| dynamicLoad("optional-depender-0.0.2.hpi"); | ||
| } | ||
| // Extension depending on dependee class isn't loaded | ||
| assertTrue(r.jenkins.getExtensionList("org.jenkinsci.plugins.dependencytest.optionaldepender.OptionalDependerExtension").isEmpty()); | ||
| // Load dependee. | ||
| { | ||
| dynamicLoad("dependee-0.0.2.hpi"); | ||
| } | ||
|
|
||
| // Extensions in depender are loaded. | ||
| assertEquals(1, r.jenkins.getExtensionList("org.jenkinsci.plugins.dependencytest.optionaldepender.OptionalDependerExtension").size()); | ||
| } | ||
|
|
||
| @Issue("JENKINS-21486") | ||
| @Test public void installPluginWithObsoleteDependencyFails() throws Exception { | ||
| // Load dependee 0.0.1. | ||
|
|
@@ -611,22 +588,6 @@ public void findResourceForPluginFirstClassLoader() { | |
| assertEquals(fromPlugin, fromToolkit); | ||
| } | ||
|
|
||
| // Sources for jenkins-50336.hpi are available at https://github.com/Vlatombe/jenkins-50336 | ||
| // | ||
| // package io.jenkins.plugins; | ||
| // import org.jenkinsci.plugins.variant.OptionalExtension; | ||
| // import jenkins.model.GlobalConfiguration; | ||
| // @OptionalExtension public class MyGlobalConfiguration extends GlobalConfiguration {} | ||
| // | ||
| @Issue("JENKINS-50336") | ||
| @Test | ||
| public void optionalExtensionCanBeFoundAfterDynamicLoadOfVariant() throws Exception { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also moved into I did make some changes though to the test to make it more closely match the scenario described in JENKINS-50336 (also so that I could delete |
||
| dynamicLoad("variant.hpi"); | ||
| assertNotNull(r.jenkins.getPluginManager().getPlugin("variant")); | ||
| dynamicLoad("jenkins-50336.hpi"); | ||
| assertTrue(ExtensionList.lookup(GlobalConfiguration.class).stream().anyMatch(gc -> "io.jenkins.plugins.MyGlobalConfiguration".equals(gc.getClass().getName()))); | ||
| } | ||
|
|
||
| @Test @Issue("JENKINS-64840") | ||
| @WithPlugin({"mandatory-depender-0.0.2.hpi", "dependee-0.0.2.hpi", "depender-0.0.2.hpi"}) | ||
| public void getPluginsSortedByTitle() throws Exception { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -190,11 +190,10 @@ public void disableAlreadyDisabledPluginNotRestart() throws Exception { | |
| @Ignore("TODO calling restart seems to break Surefire") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clearly could stand to be rewritten. |
||
| @Test | ||
| @Issue("JENKINS-27177") | ||
| @WithPlugin({"variant.hpi", "depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"}) | ||
| @WithPlugin({"depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"}) | ||
| public void restartAfterDisablePluginsAndErrors() { | ||
| assumeNotWindows(); | ||
| assertThat(disablePluginsCLiCommand("-restart", "variant", "dependee", "depender", "plugin-first", "mandatory-depender"), failedWith(RETURN_CODE_NOT_DISABLED_DEPENDANTS)); | ||
| assertPluginDisabled("variant"); | ||
| assertThat(disablePluginsCLiCommand("-restart", "dependee", "depender", "plugin-first", "mandatory-depender"), failedWith(RETURN_CODE_NOT_DISABLED_DEPENDANTS)); | ||
| assertPluginEnabled("dependee"); | ||
| assertPluginDisabled("depender"); | ||
| assertPluginDisabled("plugin-first"); | ||
|
|
@@ -207,13 +206,12 @@ public void restartAfterDisablePluginsAndErrors() { | |
| */ | ||
| @Test | ||
| @Issue("JENKINS-27177") | ||
| @WithPlugin({"variant.hpi", "depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"}) | ||
| @WithPlugin({"depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"}) | ||
| public void disablePluginsStrategyAll() { | ||
| assertPluginEnabled("dependee"); | ||
| assertPluginEnabled("depender"); | ||
| assertPluginEnabled("mandatory-depender"); | ||
| assertThat(disablePluginsCLiCommand("-strategy", "all", "variant", "dependee", "plugin-first"), succeeded()); | ||
| assertPluginDisabled("variant"); | ||
| assertThat(disablePluginsCLiCommand("-strategy", "all", "dependee", "plugin-first"), succeeded()); | ||
| assertPluginDisabled("dependee"); | ||
| assertPluginDisabled("depender"); | ||
| assertPluginDisabled("plugin-first"); | ||
|
|
@@ -225,10 +223,9 @@ public void disablePluginsStrategyAll() { | |
| */ | ||
| @Test | ||
| @Issue("JENKINS-27177") | ||
| @WithPlugin({"variant.hpi", "depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"}) | ||
| @WithPlugin({"depender-0.0.2.hpi", "mandatory-depender-0.0.2.hpi", "plugin-first.hpi", "dependee-0.0.2.hpi"}) | ||
| public void disablePluginsStrategyMandatory() { | ||
| assertThat(disablePluginsCLiCommand("-strategy", "mandatory", "variant", "dependee", "plugin-first"), succeeded()); | ||
| assertPluginDisabled("variant"); | ||
| assertThat(disablePluginsCLiCommand("-strategy", "mandatory", "dependee", "plugin-first"), succeeded()); | ||
| assertPluginDisabled("dependee"); | ||
| assertPluginEnabled("depender"); | ||
| assertPluginDisabled("plugin-first"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -139,7 +139,7 @@ public void enablePluginWithRestart() throws IOException { | |
| @Issue("JENKINS-52950") | ||
| public void enableNoPluginsWithRestartIsNoOp() { | ||
| assumeNotWindows(); | ||
| String name = "variant"; | ||
| String name = "icon-shim"; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just swapping for another no-op plugin that is already here. Probably all of these tests should be using |
||
| assertThat(installTestPlugin(name), succeeded()); | ||
| assertThat(enablePlugins("-restart", name), succeeded()); | ||
| assertJenkinsNotInQuietMode(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package jenkins.plugins.dependee; | ||
|
|
||
| public class Dependee { | ||
| public static String getValue() { | ||
| return "dependee"; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package jenkins.plugins.dependee; | ||
|
|
||
| import hudson.ExtensionPoint; | ||
|
|
||
| public class DependeeExtensionPoint implements ExtensionPoint { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package jenkins.plugins.dynamic_extension_loading; | ||
|
|
||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
| import org.jenkinsci.plugins.variant.OptionalExtension; | ||
|
|
||
| @OptionalExtension | ||
| public class CustomExtensionLoadedViaConstructor { | ||
| private static final Logger LOGGER = Logger.getLogger(CustomExtensionLoadedViaConstructor.class.getName()); | ||
|
|
||
| public CustomExtensionLoadedViaConstructor() { | ||
| LOGGER.log(Level.INFO, null, new Exception("Instantiating CustomExtensionLoadedViaConstructor")); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package jenkins.plugins.dynamic_extension_loading; | ||
|
|
||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
| import org.jenkinsci.plugins.variant.OptionalExtension; | ||
|
|
||
| @OptionalExtension | ||
| public class CustomExtensionLoadedViaListener { | ||
| private static final Logger LOGGER = Logger.getLogger(CustomExtensionLoadedViaListener.class.getName()); | ||
|
|
||
| public long recurrencePeriod = 120; | ||
|
|
||
| public CustomExtensionLoadedViaListener() { | ||
| LOGGER.log(Level.INFO, null, new Exception("Instantiating CustomExtensionLoadedViaListener")); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package jenkins.plugins.dynamic_extension_loading; | ||
|
|
||
| import hudson.ExtensionList; | ||
| import hudson.model.PeriodicWork; | ||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
| import org.jenkinsci.plugins.variant.OptionalExtension; | ||
|
|
||
| @OptionalExtension | ||
| public class CustomPeriodicWork extends PeriodicWork { | ||
|
|
||
| private static final Logger LOGGER = Logger.getLogger(CustomPeriodicWork.class.getName()); | ||
|
|
||
| public CustomPeriodicWork() { | ||
| LOGGER.log(Level.INFO, null, new Exception("Instantiating CustomPeriodicWork")); | ||
| ExtensionList.lookupSingleton(CustomExtensionLoadedViaConstructor.class); | ||
| } | ||
|
|
||
| @Override | ||
| protected void doRun() {} | ||
|
|
||
| @Override | ||
| public long getRecurrencePeriod() { | ||
| LOGGER.log(Level.INFO, null, new Exception("Loading CustomExtensionLoadedViaListener")); | ||
| return ExtensionList.lookupSingleton(CustomExtensionLoadedViaListener.class).recurrencePeriod; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| @OptionalPackage(requirePlugins = "dynamic-extension-loading") | ||
| package jenkins.plugins.dynamic_extension_loading; | ||
|
|
||
| import org.jenkinsci.plugins.variant.OptionalPackage; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is an incompatible API change, but given the Javadoc, there really should not be any external callers, and a quick GitHub search (I also checked the
cloudbeesorg) looked ok.If desired, I can instead introduce a new method and preserve the old one for compatibility. We might still want to add
@Restricted(NoExternalUse.class)to the old method if we take that approach though.