diff --git a/.gitignore b/.gitignore index 6332cf810a..4aa6095e61 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ target/ .classpath .settings .project +nbactions.xml \ No newline at end of file diff --git a/pom.xml b/pom.xml index 470cf4b166..6cee84eac7 100644 --- a/pom.xml +++ b/pom.xml @@ -57,6 +57,7 @@ 3.8.4 3.6.4 2.1.1 + 1.1.0 High @@ -94,6 +95,26 @@ maven-shared-utils 3.3.4 + + org.eclipse.aether + aether-api + ${aether.version} + + + org.eclipse.aether + aether-impl + ${aether.version} + + + org.eclipse.aether + aether-spi + ${aether.version} + + + org.eclipse.aether + aether-util + ${aether.version} + @@ -216,6 +237,11 @@ mojo-executor 2.3.3 + + org.apache.maven.shared + maven-dependency-tree + 3.1.0 + diff --git a/src/it/override-test-dependencies-smokes/invoker.properties b/src/it/override-test-dependencies-smokes/invoker.properties new file mode 100644 index 0000000000..384bd1fafb --- /dev/null +++ b/src/it/override-test-dependencies-smokes/invoker.properties @@ -0,0 +1 @@ +invoker.goals=-ntp test diff --git a/src/it/override-test-dependencies-smokes/pom.xml b/src/it/override-test-dependencies-smokes/pom.xml new file mode 100644 index 0000000000..b77d0c63a2 --- /dev/null +++ b/src/it/override-test-dependencies-smokes/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + org.jenkins-ci.plugins + plugin + 4.37 + + + org.jenkins-ci.tools.hpi.its + override-test-dependencies-smokes + 1.0-SNAPSHOT + hpi + + 2.249 + 8 + @project.version@ + + org.jenkins-ci.plugins.workflow:workflow-step-api:2.11,org.jenkins-ci.plugins.workflow:workflow-api:2.17,org.jenkins-ci.plugins.workflow:workflow-cps:2.32 + SampleTest + false + 0 + 2.9 + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + org.jenkins-ci.plugins + structs + 1.6 + + + org.jenkins-ci.plugins.workflow + workflow-step-api + ${workflow-step-api-plugin.version} + + + org.jenkins-ci.plugins.workflow + workflow-step-api + ${workflow-step-api-plugin.version} + tests + test + + + org.jenkins-ci.plugins.workflow + workflow-cps + 2.30 + test + + + antlr + antlr + + + + + diff --git a/src/it/override-test-dependencies-smokes/src/main/resources/index.jelly b/src/it/override-test-dependencies-smokes/src/main/resources/index.jelly new file mode 100644 index 0000000000..2f655e510a --- /dev/null +++ b/src/it/override-test-dependencies-smokes/src/main/resources/index.jelly @@ -0,0 +1,2 @@ + +
diff --git a/src/it/override-test-dependencies-smokes/src/test/java/test/SampleTest.java b/src/it/override-test-dependencies-smokes/src/test/java/test/SampleTest.java new file mode 100644 index 0000000000..a0a94d0658 --- /dev/null +++ b/src/it/override-test-dependencies-smokes/src/test/java/test/SampleTest.java @@ -0,0 +1,42 @@ +package test; + +import com.google.common.collect.ImmutableMap; +import hudson.remoting.Which; +import java.io.InputStream; +import java.net.URL; +import java.util.Enumeration; +import java.util.Map; +import java.util.jar.Manifest; +import org.jenkinsci.plugins.workflow.steps.StepConfigTester; +import org.junit.Test; +import static org.junit.Assert.*; +import org.junit.Rule; +import org.jvnet.hudson.test.JenkinsRule; + +public class SampleTest { + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Test + public void smokes() throws Exception { + Map expectedVersions = ImmutableMap.of("workflow-step-api", "2.11", "workflow-api", "2.17", "workflow-cps", "2.32"); + Enumeration manifests = SampleTest.class.getClassLoader().getResources("META-INF/MANIFEST.MF"); + while (manifests.hasMoreElements()) { + URL url = manifests.nextElement(); + try (InputStream is = url.openStream()) { + Manifest mf = new Manifest(is); + String pluginName = mf.getMainAttributes().getValue("Short-Name"); + String expectedVersion = expectedVersions.get(pluginName); + if (expectedVersion != null) { + assertEquals("wrong version for " + pluginName + " as classpath entry", expectedVersion, mf.getMainAttributes().getValue("Plugin-Version")); + } + } + } + for (Map.Entry entry : expectedVersions.entrySet()) { + assertEquals("wrong version for " + entry.getKey() + " as plugin", entry.getValue(), r.jenkins.pluginManager.getPlugin(entry.getKey()).getVersion()); + } + assertEquals("workflow-step-api-2.11-tests.jar", Which.jarFile(StepConfigTester.class).getName()); + } + +} diff --git a/src/it/override-test-dependencies-smokes/verify.groovy b/src/it/override-test-dependencies-smokes/verify.groovy new file mode 100644 index 0000000000..a4b7f510d8 --- /dev/null +++ b/src/it/override-test-dependencies-smokes/verify.groovy @@ -0,0 +1,3 @@ +def log = new File(basedir, 'build.log').text +// TODO add anything needed, or delete this file +true \ No newline at end of file diff --git a/src/it/override-test-dependencies-useUpperBounds/invoker.properties b/src/it/override-test-dependencies-useUpperBounds/invoker.properties new file mode 100644 index 0000000000..384bd1fafb --- /dev/null +++ b/src/it/override-test-dependencies-useUpperBounds/invoker.properties @@ -0,0 +1 @@ +invoker.goals=-ntp test diff --git a/src/it/override-test-dependencies-useUpperBounds/pom.xml b/src/it/override-test-dependencies-useUpperBounds/pom.xml new file mode 100644 index 0000000000..892bb00e14 --- /dev/null +++ b/src/it/override-test-dependencies-useUpperBounds/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + org.jenkins-ci.plugins + plugin + 2.32 + + + org.jenkins-ci.tools.hpi.its + override-test-dependencies-useUpperBounds + 1.0-SNAPSHOT + hpi + + 1.642.3 + @project.version@ + org.jenkins-ci.plugins.workflow:workflow-cps:2.33 + true + SampleTest + false + 0 + 2.9 + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + org.jenkins-ci.plugins + structs + 1.6 + + + org.jenkins-ci.plugins.workflow + workflow-step-api + ${workflow-step-api-plugin.version} + + + org.jenkins-ci.plugins.workflow + workflow-step-api + ${workflow-step-api-plugin.version} + tests + test + + + org.jenkins-ci.plugins.workflow + workflow-cps + 2.30 + test + + + diff --git a/src/it/override-test-dependencies-useUpperBounds/src/main/resources/index.jelly b/src/it/override-test-dependencies-useUpperBounds/src/main/resources/index.jelly new file mode 100644 index 0000000000..2f655e510a --- /dev/null +++ b/src/it/override-test-dependencies-useUpperBounds/src/main/resources/index.jelly @@ -0,0 +1,2 @@ + +
diff --git a/src/it/override-test-dependencies-useUpperBounds/src/test/java/test/SampleTest.java b/src/it/override-test-dependencies-useUpperBounds/src/test/java/test/SampleTest.java new file mode 100644 index 0000000000..248b4c3724 --- /dev/null +++ b/src/it/override-test-dependencies-useUpperBounds/src/test/java/test/SampleTest.java @@ -0,0 +1,45 @@ +package test; + +import com.google.common.collect.ImmutableMap; +import hudson.remoting.Which; +import java.io.InputStream; +import java.net.URL; +import java.util.Enumeration; +import java.util.Map; +import java.util.jar.Manifest; +import jenkins.model.Jenkins; +import org.jenkinsci.plugins.workflow.steps.StepConfigTester; +import org.junit.Test; +import static org.junit.Assert.*; +import org.junit.Rule; +import org.jvnet.hudson.test.JenkinsRule; + +public class SampleTest { + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Test + public void smokes() throws Exception { + Map expectedVersions = ImmutableMap.of("structs", "1.7", "workflow-step-api", "2.10", "workflow-api", "2.16", "workflow-cps", "2.33"); + Enumeration manifests = SampleTest.class.getClassLoader().getResources("META-INF/MANIFEST.MF"); + while (manifests.hasMoreElements()) { + URL url = manifests.nextElement(); + try (InputStream is = url.openStream()) { + Manifest mf = new Manifest(is); + String pluginName = mf.getMainAttributes().getValue("Short-Name"); + String expectedVersion = expectedVersions.get(pluginName); + if (expectedVersion != null) { + assertEquals("wrong version for " + pluginName + " as classpath entry", expectedVersion, mf.getMainAttributes().getValue("Plugin-Version")); + } + } + } + for (Map.Entry entry : expectedVersions.entrySet()) { + assertEquals("wrong version for " + entry.getKey() + " as plugin", entry.getValue(), r.jenkins.pluginManager.getPlugin(entry.getKey()).getVersion()); + } + assertEquals("workflow-step-api-2.10-tests.jar", Which.jarFile(StepConfigTester.class).getName()); + assertEquals("2.7.3", Jenkins.VERSION); + assertEquals("jenkins-war-2.7.3.war", /* like WarExploder */Which.jarFile(Class.forName("executable.Executable")).getName()); + } + +} diff --git a/src/main/java/org/jenkinsci/maven/plugins/hpi/TestDependencyMojo.java b/src/main/java/org/jenkinsci/maven/plugins/hpi/TestDependencyMojo.java index b928d9b0ae..3b152909e2 100644 --- a/src/main/java/org/jenkinsci/maven/plugins/hpi/TestDependencyMojo.java +++ b/src/main/java/org/jenkinsci/maven/plugins/hpi/TestDependencyMojo.java @@ -1,11 +1,5 @@ package org.jenkinsci.maven.plugins.hpi; -import org.apache.commons.io.FileUtils; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.MojoFailureException; -import org.apache.maven.plugins.annotations.Mojo; -import org.apache.maven.plugins.annotations.ResolutionScope; - import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -13,6 +7,37 @@ import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.DefaultArtifact; +import org.apache.maven.artifact.versioning.ArtifactVersion; +import org.apache.maven.artifact.versioning.DefaultArtifactVersion; +import org.apache.maven.artifact.versioning.OverConstrainedVersionException; +import org.apache.maven.artifact.versioning.VersionRange; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Component; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; +import org.apache.maven.shared.dependency.graph.DependencyCollectorBuilder; +import org.apache.maven.shared.dependency.graph.DependencyCollectorBuilderException; +import org.apache.maven.shared.dependency.graph.DependencyNode; +import org.apache.maven.shared.dependency.graph.traversal.DependencyNodeVisitor; +import org.apache.maven.shared.transfer.artifact.resolve.ArtifactResolverException; /** * Places test-dependency plugins into somewhere the test harness can pick up. @@ -20,13 +45,44 @@ *

* See {@code TestPluginManager.loadBundledPlugins()} where the test harness uses it. * - * @author Kohsuke Kawaguchi + *

Additionally, it may adjust the classpath for {@code surefire:test} to run tests + * against different versions of various dependencies than what was configured in the POM. */ @Mojo(name="resolve-test-dependencies", requiresDependencyResolution = ResolutionScope.TEST) public class TestDependencyMojo extends AbstractHpiMojo { + + @Component + private DependencyCollectorBuilder dependencyCollectorBuilder; + + /** + * List of dependency version overrides in the form {@code groupId:artifactId:version} to apply during testing. + * Must correspond to dependencies already present in the project model. + */ + @Parameter(property="overrideVersions") + private List overrideVersions; + + /** + * Whether to update all transitive dependencies to the upper bounds. + * Effectively causes same behavior as the {@code requireUpperBoundDeps} Enforcer rule would, + * if the specified dependencies were to be written to the POM. + * Intended for use in conjunction with {@link #overrideVersions}. + */ + @Parameter(property="useUpperBounds") + private boolean useUpperBounds; + @Override public void execute() throws MojoExecutionException, MojoFailureException { - File testDir = new File(project.getBuild().getTestOutputDirectory(), "test-dependencies"); + Map overrides = new HashMap<>(); // groupId:artifactId → version + if (overrideVersions != null) { + for (String override : overrideVersions) { + Matcher m = Pattern.compile("([^:]+:[^:]+):([^:]+)").matcher(override); + if (!m.matches()) { + throw new MojoExecutionException("illegal override: " + override); + } + overrides.put(m.group(1), m.group(2)); + } + } + File testDir = new File(project.getBuild().getTestOutputDirectory(),"test-dependencies"); try { Files.createDirectories(testDir.toPath()); } catch (IOException e) { @@ -46,11 +102,229 @@ public void execute() throws MojoExecutionException, MojoFailureException { getLog().debug("Copying " + artifactId + " as a test dependency"); File dst = new File(testDir, artifactId + ".hpi"); - FileUtils.copyFile(a.getHpi().getFile(),dst); + File src; + String version = overrides.get(a.getGroupId() + ":" + artifactId); + if (version != null) { + src = replace(a.getHpi().artifact, version).getFile(); + } else { + src = a.getHpi().getFile(); + } + FileUtils.copyFile(src, dst); w.write(artifactId + "\n"); } } catch (IOException e) { throw new MojoExecutionException("Failed to copy dependency plugins",e); } + + if (overrideVersions != null) { + if (useUpperBounds) { + DependencyNode node; + try { + MavenProject shadow = project.clone(); + // first pass: adjust direct dependencies in place + Set updated = new HashSet<>(); + @SuppressWarnings("unchecked") + Set dependencyArtifacts = shadow.getDependencyArtifacts(); // mutable; seems to be what DefaultDependencyTreeBuilder cares about + for (Artifact art : dependencyArtifacts) { + String key = art.getGroupId() + ":" + art.getArtifactId(); + String overrideVersion = overrides.get(key); + if (overrideVersion != null) { + getLog().debug("For dependency analysis, updating " + key + " from " + art.getVersion() + " to " + overrideVersion); + art.setVersion(overrideVersion); + updated.add(key); + } + } + // second pass: add direct dependencies for transitive dependencies that need to be bumped + @SuppressWarnings("unchecked") + Set artifacts = shadow.getArtifacts(); + Set transitiveUpdated = new HashSet<>(); + for (Artifact art : artifacts) { + String key = art.getGroupId() + ":" + art.getArtifactId(); + if (updated.contains(key)) { + continue; // already handled above + } + String overrideVersion = overrides.get(key); + if (overrideVersion != null) { + getLog().info("For dependency analysis, updating transitive " + key + " from " + art.getVersion() + " to " + overrideVersion); + dependencyArtifacts.add(replace(art, overrideVersion)); + transitiveUpdated.add(key); + } + } + Set unapplied = new HashSet<>(overrides.keySet()); + unapplied.removeAll(updated); + unapplied.removeAll(transitiveUpdated); + if (!unapplied.isEmpty()) { + throw new MojoFailureException("could not find dependencies " + unapplied); + } + getLog().debug("adjusted dependencyArtifacts: " + dependencyArtifacts); + node = dependencyCollectorBuilder.collectDependencyGraph(session.getProjectBuildingRequest(), /* all scopes */null); + } catch (DependencyCollectorBuilderException x) { + throw new MojoExecutionException("could not analyze dependency tree for useUpperBounds: " + x, x); + } + RequireUpperBoundDepsVisitor visitor = new RequireUpperBoundDepsVisitor(); + node.accept(visitor); + Map upperBounds = visitor.upperBounds(); + if (!upperBounds.isEmpty()) { + getLog().debug("Applying upper bounds: " + upperBounds); + overrides.putAll(upperBounds); + } + } + List additionalClasspathElements = new ArrayList<>(); + List classpathDependencyExcludes = new ArrayList<>(); + for (Map.Entry entry : overrides.entrySet()) { + String key = entry.getKey(); + classpathDependencyExcludes.add(key); + String[] groupArt = key.split(":"); + String groupId = groupArt[0]; + String artifactId = groupArt[1]; + String version = entry.getValue(); + // Cannot use MavenProject.getArtifactMap since we may have multiple dependencies of different classifiers. + boolean found = false; + for (Object _a : project.getArtifacts()) { + Artifact a = (Artifact) _a; + if (!a.getGroupId().equals(groupId) || !a.getArtifactId().equals(artifactId)) { + continue; + } + found = true; + if (a.getArtifactHandler().isAddedToClasspath()) { // everything is added to test CP, so no need to check scope + additionalClasspathElements.add(replace(a, version).getFile().getAbsolutePath()); + } + } + if (!found) { + throw new MojoExecutionException("could not find dependency " + key); + } + } + Properties properties = project.getProperties(); + getLog().info("Replacing POM-defined classpath elements " + classpathDependencyExcludes + " with " + additionalClasspathElements); + // cf. http://maven.apache.org/surefire/maven-surefire-plugin/test-mojo.html + properties.setProperty("maven.test.additionalClasspath", StringUtils.join(additionalClasspathElements, ',')); + properties.setProperty("maven.test.dependency.excludes", StringUtils.join(classpathDependencyExcludes, ',')); + } } + + private Artifact replace(Artifact a, String version) throws MojoExecutionException { + Artifact a2 = new DefaultArtifact(a.getGroupId(), a.getArtifactId(), VersionRange.createFromVersion(version), a.getScope(), a.getType(), a.getClassifier(), a.getArtifactHandler(), a.isOptional()); + try { + return artifactResolver.resolveArtifact(session.getProjectBuildingRequest(), a2).getArtifact(); + } catch (ArtifactResolverException x) { + throw new MojoExecutionException("could not find " + a + " in version " + version + ": " + x, x); + } + } + + // Adapted from RequireUpperBoundDeps @ 731ea7a693a0986f2054b6a73a86a31373df59ec. TODO delete extraneous stuff and simplify to the logic we actually need here: + private class RequireUpperBoundDepsVisitor implements DependencyNodeVisitor { + + private boolean uniqueVersions; + + public void setUniqueVersions(boolean uniqueVersions) { + this.uniqueVersions = uniqueVersions; + } + + private Map> keyToPairsMap + = new LinkedHashMap>(); + + public boolean visit(DependencyNode node) { + DependencyNodeHopCountPair pair = new DependencyNodeHopCountPair(node); + String key = pair.constructKey(); + List pairs = keyToPairsMap.get(key); + if (pairs == null) { + pairs = new ArrayList(); + keyToPairsMap.put(key, pairs); + } + pairs.add(pair); + Collections.sort(pairs); + return true; + } + + public boolean endVisit(DependencyNode node) { + return true; + } + + // added for TestDependencyMojo in place of getConflicts/containsConflicts + @SuppressWarnings("unchecked") + public Map upperBounds() { + Map r = new HashMap<>(); + // TODO this does not suffice; does not find that workflow-api needs to go from 2.11 to 2.16, presumably because it was not a direct dependency to begin with + for (List pairs : keyToPairsMap.values()) { + DependencyNodeHopCountPair resolvedPair = pairs.get(0); + + // search for artifact with lowest hopCount + for (DependencyNodeHopCountPair hopPair : pairs.subList(1, pairs.size())) { + if (hopPair.getHopCount() < resolvedPair.getHopCount()) { + resolvedPair = hopPair; + } + } + + ArtifactVersion resolvedVersion = resolvedPair.extractArtifactVersion(uniqueVersions, false); + + for (DependencyNodeHopCountPair pair : pairs) { + ArtifactVersion version = pair.extractArtifactVersion(uniqueVersions, true); + if (resolvedVersion.compareTo(version) < 0) { + Artifact artifact = resolvedPair.node.getArtifact(); + String key = artifact.getGroupId() + ":" + artifact.getArtifactId(); + getLog().info("for " + key + ", upper bounds forces an upgrade from " + resolvedVersion + " to " + version); + r.put(key, version.toString()); + } + } + } + return r; + } + + } + + private static class DependencyNodeHopCountPair implements Comparable { + + private DependencyNode node; + + private int hopCount; + + private DependencyNodeHopCountPair(DependencyNode node) { + this.node = node; + countHops(); + } + + private void countHops() { + hopCount = 0; + DependencyNode parent = node.getParent(); + while (parent != null) { + hopCount++; + parent = parent.getParent(); + } + } + + private String constructKey() { + Artifact artifact = node.getArtifact(); + return artifact.getGroupId() + ":" + artifact.getArtifactId(); + } + + public DependencyNode getNode() { + return node; + } + + private ArtifactVersion extractArtifactVersion(boolean uniqueVersions, boolean usePremanagedVersion) { + if (usePremanagedVersion && node.getPremanagedVersion() != null) { + return new DefaultArtifactVersion(node.getPremanagedVersion()); + } + + Artifact artifact = node.getArtifact(); + String version = uniqueVersions ? artifact.getVersion() : artifact.getBaseVersion(); + if (version != null) { + return new DefaultArtifactVersion(version); + } + try { + return artifact.getSelectedVersion(); + } catch (OverConstrainedVersionException e) { + throw new RuntimeException("Version ranges problem with " + node.getArtifact(), e); + } + } + + public int getHopCount() { + return hopCount; + } + + public int compareTo(DependencyNodeHopCountPair other) { + return Integer.compare(hopCount, other.getHopCount()); + } + } + }