From d57db1b1f2e30917c337eabdc0c204a832fb8d0a Mon Sep 17 00:00:00 2001 From: Vincent Latombe Date: Fri, 25 Mar 2016 18:23:25 +0100 Subject: [PATCH] [JENKINS-21486] Fix plugin dependencies resolution * Check that dependencies are enabled. A disabled optional dependency will not prevent a plugin from loading. * Check versions of dependencies declared by a plugin before loading it. If any dependency (even optional) is older than what is required, then the plugin isn't loaded. This should prevent use cases where a plugin is loaded but one of its dependencies is too old so that : * its @Extension annotated classes cannot be loaded, causing the full Jenkins to blow up with crapload of exceptions which are tedious to investigate to understand the root cause. * NoSuchMethodError and the likes at runtime even though boot has completed. Version check (for setups where version list is manually crafted but yet works) can be disabled by starting Jenkins with -Dhudson.PluginWrapper.dependenciesVersionCheck.enabled=true Minor fixes done while implementing this change : * Fix version parsing in PluginWrapper.Dependency * Dynamic plugin load didn't check for disabled flag --- core/src/main/java/hudson/PluginManager.java | 3 +- core/src/main/java/hudson/PluginWrapper.java | 73 ++++++++++++++--- .../test/java/hudson/PluginManagerTest.java | 74 ++++++++++++++++++ .../test/java/hudson/PluginManagerUtil.java | 7 ++ .../test/java/hudson/PluginWrapperTest.java | 26 ++++++ .../test/resources/plugins/dependee-0.0.2.hpi | Bin 0 -> 4671 bytes .../test/resources/plugins/depender-0.0.2.hpi | Bin 0 -> 5500 bytes .../plugins/mandatory-depender-0.0.2.hpi | Bin 0 -> 5587 bytes 8 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 test/src/test/java/hudson/PluginWrapperTest.java create mode 100644 test/src/test/resources/plugins/dependee-0.0.2.hpi create mode 100644 test/src/test/resources/plugins/depender-0.0.2.hpi create mode 100644 test/src/test/resources/plugins/mandatory-depender-0.0.2.hpi diff --git a/core/src/main/java/hudson/PluginManager.java b/core/src/main/java/hudson/PluginManager.java index 7aa8a6202f..9d1a50d27e 100644 --- a/core/src/main/java/hudson/PluginManager.java +++ b/core/src/main/java/hudson/PluginManager.java @@ -489,7 +489,8 @@ public abstract class PluginManager extends AbstractModelObject implements OnMas // so existing plugins can't be depending on this newly deployed one. plugins.add(p); - activePlugins.add(p); + if (p.isActive()) + activePlugins.add(p); synchronized (((UberClassLoader) uberClassLoader).loaded) { ((UberClassLoader) uberClassLoader).loaded.clear(); } diff --git a/core/src/main/java/hudson/PluginWrapper.java b/core/src/main/java/hudson/PluginWrapper.java index 60eabf68cf..71cdf14096 100644 --- a/core/src/main/java/hudson/PluginWrapper.java +++ b/core/src/main/java/hudson/PluginWrapper.java @@ -87,6 +87,12 @@ import javax.annotation.Nonnull; */ @ExportedBean public class PluginWrapper implements Comparable, ModelObject { + /** + * A plugin won't be loaded unless his declared dependencies are present and match the required minimal version. + * This can be set to false to disable the version check (legacy behaviour) + */ + public static final boolean ENABLE_PLUGIN_DEPENDENCIES_VERSION_CHECK = Boolean.parseBoolean(System.getProperty(PluginWrapper.class.getName()+"." + "dependenciesVersionCheck.enabled", "true")); + /** * {@link PluginManager} to which this belongs to. */ @@ -217,10 +223,10 @@ public class PluginWrapper implements Comparable, ModelObject { if(idx==-1) throw new IllegalArgumentException("Illegal dependency specifier "+s); this.shortName = s.substring(0,idx); - this.version = s.substring(idx+1); - + String version = s.substring(idx+1); + boolean isOptional = false; - String[] osgiProperties = s.split(";"); + String[] osgiProperties = version.split("[;]"); for (int i = 1; i < osgiProperties.length; i++) { String osgiProperty = osgiProperties[i].trim(); if (osgiProperty.equalsIgnoreCase("resolution:=optional")) { @@ -228,11 +234,16 @@ public class PluginWrapper implements Comparable, ModelObject { } } this.optional = isOptional; + if (isOptional) { + this.version = osgiProperties[0]; + } else { + this.version = version; + } } @Override public String toString() { - return shortName + " (" + version + ")"; + return shortName + " (" + version + ") " + (optional ? "optional" : ""); } } @@ -524,18 +535,60 @@ public class PluginWrapper implements Comparable, ModelObject { */ /*package*/ void resolvePluginDependencies() throws IOException { List missingDependencies = new ArrayList(); + List obsoleteDependencies = new ArrayList(); + List disabledDependencies = new ArrayList(); // make sure dependencies exist for (Dependency d : dependencies) { - if (parent.getPlugin(d.shortName) == null) + PluginWrapper dependency = parent.getPlugin(d.shortName); + if (dependency == null) { missingDependencies.add(d.toString()); - } - if (!missingDependencies.isEmpty()) - throw new IOException("Dependency "+Util.join(missingDependencies, ", ")+" doesn't exist"); + } else { + if (dependency.isActive()) { + if (ENABLE_PLUGIN_DEPENDENCIES_VERSION_CHECK && dependency.getVersionNumber().isOlderThan(new VersionNumber(d.version))) { + obsoleteDependencies.add(dependency.getShortName() + "(" + dependency.getVersion() + " < " + d.version + ")"); + } + } else { + disabledDependencies.add(d.toString()); + } + } + } // add the optional dependencies that exists for (Dependency d : optionalDependencies) { - if (parent.getPlugin(d.shortName) != null) - dependencies.add(d); + PluginWrapper dependency = parent.getPlugin(d.shortName); + if (dependency != null && dependency.isActive()) { + if (ENABLE_PLUGIN_DEPENDENCIES_VERSION_CHECK && dependency.getVersionNumber().isOlderThan(new VersionNumber(d.version))) { + obsoleteDependencies.add(dependency.getShortName() + "(" + dependency.getVersion() + " < " + d.version + ")"); + } else { + dependencies.add(d); + } + } + } + StringBuilder messageBuilder = new StringBuilder(); + if (!missingDependencies.isEmpty()) { + boolean plural = missingDependencies.size() > 1; + messageBuilder.append(plural ? "Dependencies " : "Dependency ") + .append(Util.join(missingDependencies, ", ")) + .append(" ").append(plural ? "don't" : "doesn't") + .append(" exist. "); + } + if (!disabledDependencies.isEmpty()) { + boolean plural = disabledDependencies.size() > 1; + messageBuilder.append(plural ? "Dependencies " : "Dependency ") + .append(Util.join(missingDependencies, ", ")) + .append(" ").append(plural ? "are" : "is") + .append(" disabled. "); + } + if (!obsoleteDependencies.isEmpty()) { + boolean plural = obsoleteDependencies.size() > 1; + messageBuilder.append(plural ? "Dependencies " : "Dependency ") + .append(Util.join(obsoleteDependencies, ", ")) + .append(" ").append(plural ? "are" : "is") + .append(" older than required."); + } + String message = messageBuilder.toString(); + if (!message.isEmpty()) { + throw new IOException(message); } } diff --git a/test/src/test/java/hudson/PluginManagerTest.java b/test/src/test/java/hudson/PluginManagerTest.java index 282bc9f779..02e3e21bd2 100644 --- a/test/src/test/java/hudson/PluginManagerTest.java +++ b/test/src/test/java/hudson/PluginManagerTest.java @@ -45,6 +45,7 @@ import jenkins.RestartRequiredException; import org.apache.commons.io.FileUtils; import org.apache.tools.ant.filters.StringInputStream; import static org.junit.Assert.*; + import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -337,6 +338,75 @@ public class PluginManagerTest { assertTrue(r.jenkins.getExtensionList("org.jenkinsci.plugins.dependencytest.dependee.DependeeExtensionPoint").isEmpty()); } + @Issue("JENKINS-21486") + @Test public void installPluginWithObsoleteDependencyFails() throws Exception { + // Load dependee 0.0.1. + { + dynamicLoad("dependee.hpi"); + } + + // Load mandatory-depender 0.0.2, depending on dependee 0.0.2 + try { + dynamicLoad("mandatory-depender-0.0.2.hpi"); + fail("Should not have worked"); + } catch (IOException e) { + // Expected + } + } + + @Issue("JENKINS-21486") + @Test public void installPluginWithDisabledOptionalDependencySucceeds() throws Exception { + // Load dependee 0.0.2. + { + dynamicLoadAndDisable("dependee-0.0.2.hpi"); + } + + // Load depender 0.0.2, depending optionally on dependee 0.0.2 + { + dynamicLoad("depender-0.0.2.hpi"); + } + + // dependee is not loaded so we cannot list any extension for it. + try { + r.jenkins.getExtensionList("org.jenkinsci.plugins.dependencytest.dependee.DependeeExtensionPoint"); + fail(); + } catch( ClassNotFoundException _ ){ + } + } + + @Issue("JENKINS-21486") + @Test public void installPluginWithDisabledDependencyFails() throws Exception { + // Load dependee 0.0.2. + { + dynamicLoadAndDisable("dependee-0.0.2.hpi"); + } + + // Load mandatory-depender 0.0.2, depending on dependee 0.0.2 + try { + dynamicLoad("mandatory-depender-0.0.2.hpi"); + fail("Should not have worked"); + } catch (IOException e) { + // Expected + } + } + + + @Issue("JENKINS-21486") + @Test public void installPluginWithObsoleteOptionalDependencyFails() throws Exception { + // Load dependee 0.0.1. + { + dynamicLoad("dependee.hpi"); + } + + // Load depender 0.0.2, depending optionally on dependee 0.0.2 + try { + dynamicLoad("depender-0.0.2.hpi"); + fail("Should not have worked"); + } catch (IOException e) { + // Expected + } + } + @Issue("JENKINS-12753") @WithPlugin("tasks.jpi") @Test public void dynamicLoadRestartRequiredException() throws Exception { @@ -358,4 +428,8 @@ public class PluginManagerTest { private void dynamicLoad(String plugin) throws IOException, InterruptedException, RestartRequiredException { PluginManagerUtil.dynamicLoad(plugin, r.jenkins); } + + private void dynamicLoadAndDisable(String plugin) throws IOException, InterruptedException, RestartRequiredException { + PluginManagerUtil.dynamicLoad(plugin, r.jenkins, true); + } } diff --git a/test/src/test/java/hudson/PluginManagerUtil.java b/test/src/test/java/hudson/PluginManagerUtil.java index e67b9b31f9..2263622b4c 100644 --- a/test/src/test/java/hudson/PluginManagerUtil.java +++ b/test/src/test/java/hudson/PluginManagerUtil.java @@ -48,9 +48,16 @@ public class PluginManagerUtil { } public static void dynamicLoad(String plugin, Jenkins jenkins) throws IOException, InterruptedException, RestartRequiredException { + dynamicLoad(plugin, jenkins, false); + } + + public static void dynamicLoad(String plugin, Jenkins jenkins, boolean disable) throws IOException, InterruptedException, RestartRequiredException { URL src = PluginManagerTest.class.getClassLoader().getResource("plugins/" + plugin); File dest = new File(jenkins.getRootDir(), "plugins/" + plugin); FileUtils.copyURLToFile(src, dest); + if (disable) { + new File(dest.getPath() + ".disabled").createNewFile(); + } jenkins.pluginManager.dynamicLoad(dest); } } diff --git a/test/src/test/java/hudson/PluginWrapperTest.java b/test/src/test/java/hudson/PluginWrapperTest.java new file mode 100644 index 0000000000..7529c53194 --- /dev/null +++ b/test/src/test/java/hudson/PluginWrapperTest.java @@ -0,0 +1,26 @@ +package hudson; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class PluginWrapperTest { + + @Test + public void dependencyTest() { + String version = "plugin:0.0.2"; + PluginWrapper.Dependency dependency = new PluginWrapper.Dependency(version); + assertEquals("plugin", dependency.shortName); + assertEquals("0.0.2", dependency.version); + assertEquals(false, dependency.optional); + } + + @Test + public void optionalDependencyTest() { + String version = "plugin:0.0.2;resolution:=optional"; + PluginWrapper.Dependency dependency = new PluginWrapper.Dependency(version); + assertEquals("plugin", dependency.shortName); + assertEquals("0.0.2", dependency.version); + assertEquals(true, dependency.optional); + } +} diff --git a/test/src/test/resources/plugins/dependee-0.0.2.hpi b/test/src/test/resources/plugins/dependee-0.0.2.hpi new file mode 100644 index 0000000000000000000000000000000000000000..79525b08464fdfe9ef41c5e7f18b4ab3afb78705 GIT binary patch literal 4671 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 literal 0 HcmV?d00001