Skip to content

Commit

Permalink
RealJenkinsRule.addSyntheticPlugin (#659)
Browse files Browse the repository at this point in the history
Co-authored-by: Vincent Latombe <[email protected]>
  • Loading branch information
jglick and Vlatombe authored Nov 19, 2024
1 parent 2e181ee commit 13b7b3d
Show file tree
Hide file tree
Showing 3 changed files with 257 additions and 6 deletions.
165 changes: 159 additions & 6 deletions src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.Extension;
import hudson.ExtensionList;
import hudson.model.UnprotectedRootAction;
import hudson.security.ACL;
Expand All @@ -36,6 +37,7 @@
import hudson.util.NamingThreadFactory;
import hudson.util.StreamCopyThread;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
Expand All @@ -54,6 +56,7 @@
import java.net.MalformedURLException;
import java.net.SocketException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLClassLoader;
import java.net.URLConnection;
Expand Down Expand Up @@ -88,8 +91,11 @@
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarInputStream;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.logging.ConsoleHandler;
import java.util.logging.Handler;
Expand All @@ -99,6 +105,8 @@
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
Expand Down Expand Up @@ -160,7 +168,6 @@
* <p>Systems not yet tested:
* <ul>
* <li>Possibly {@link Timeout} can be used.
* <li>Possibly {@link ExtensionList#add(Object)} can be used as an alternative to {@link TestExtension}.
* </ul>
*/
public final class RealJenkinsRule implements TestRule {
Expand Down Expand Up @@ -199,6 +206,8 @@ public final class RealJenkinsRule implements TestRule {

private final Set<String> extraPlugins = new TreeSet<>();

private final List<SyntheticPlugin> syntheticPlugins = new ArrayList<>();

private final Set<String> skippedPlugins = new TreeSet<>();

private final List<String> javaOptions = new ArrayList<>();
Expand Down Expand Up @@ -241,13 +250,14 @@ public RealJenkinsRule() {
/**
* Links this rule to another, with {@link #getHome} to be initialized by whichever copy starts first.
* Also copies configuration related to the setup of that directory:
* {@link #includeTestClasspathPlugins(boolean)}, {@link #addPlugins}, and {@link #omitPlugins}.
* {@link #includeTestClasspathPlugins(boolean)}, {@link #addPlugins}, {@link #addSyntheticPlugin}, and {@link #omitPlugins}.
* Other configuration such as {@link #javaOptions(String...)} may be applied to both, but that is your choice.
*/
public RealJenkinsRule(RealJenkinsRule source) {
this.home = source.home;
this.includeTestClasspathPlugins = source.includeTestClasspathPlugins;
this.extraPlugins.addAll(source.extraPlugins);
this.syntheticPlugins.addAll(source.syntheticPlugins);
this.skippedPlugins.addAll(source.skippedPlugins);
}

Expand All @@ -256,8 +266,7 @@ public RealJenkinsRule(RealJenkinsRule source) {
*
* @param plugins Filenames of the plugins to install. These are expected to be absolute test classpath resources,
* such as {@code plugins/workflow-job.hpi} for example.
* <p>Committing that file to SCM (say, {@code src/test/resources/sample.jpi}) is
* reasonable for small fake plugins built for this purpose and exercising some bit of code.
* <p>For small fake plugins built for this purpose and exercising some bit of code, use {@link #addSyntheticPlugin}.
* If you wish to test with larger archives of real plugins, this is possible for example by
* binding {@code dependency:copy} to the {@code process-test-resources} phase.
* <p>In most cases you do not need this method. Simply add whatever plugins you are
Expand All @@ -271,6 +280,28 @@ public RealJenkinsRule addPlugins(String... plugins) {
return this;
}

/**
* Adds a test-only plugin to the controller based on sources defined in this module.
* Useful when you wish to define some types, register some {@link Extension}s, etc.
* and there is no existing plugin that does quite what you want
* (that you are comfortable adding to the test classpath and maintaining the version of).
* <p>If you also have some test suites based on {@link JenkinsRule},
* you may not want to use {@link Extension} since (unlike {@link TestExtension})
* it would be loaded in all such tests.
* Instead create a {@code package-info.java} specifying an {@code @OptionalPackage}
* whose {@code requirePlugins} lists the same {@link SyntheticPlugin#shortName(String)}.
* (You will need to {@code .header("Plugin-Dependencies", "variant:0")} to use this API.)
* Then use {@code @OptionalExtension} on all your test extensions.
* These will then be loaded only in {@link RealJenkinsRule}-based tests requesting this plugin.
* @param pkg the Java package containing any classes and resources you want included
* @return a builder
*/
public SyntheticPlugin addSyntheticPlugin(Package pkg) {
SyntheticPlugin p = new SyntheticPlugin(pkg.getName());
syntheticPlugins.add(p);
return p;
}

/**
* Omit some plugins in the test classpath.
* @param plugins one or more code names, like {@code token-macro}
Expand Down Expand Up @@ -583,9 +614,10 @@ private void provision() throws Exception {

File plugins = new File(getHome(), "plugins");
Files.createDirectories(plugins.toPath());
// set the version to the version of jenkins used for testing to avoid dragging in detached plugins
String targetJenkinsVersion;
try (JarFile jf = new JarFile(war)) {
// set the version to the version of jenkins used for testing to avoid dragging in detached plugins
String targetJenkinsVersion = jf.getManifest().getMainAttributes().getValue("Jenkins-Version");
targetJenkinsVersion = jf.getManifest().getMainAttributes().getValue("Jenkins-Version");
PluginUtils.createRealJenkinsRulePlugin(plugins, targetJenkinsVersion);
}

Expand Down Expand Up @@ -671,6 +703,9 @@ private void provision() throws Exception {
}
FileUtils.copyURLToFile(url, new File(plugins, name + ".jpi"));
}
for (SyntheticPlugin syntheticPlugin : syntheticPlugins) {
syntheticPlugin.writeTo(new File(plugins, syntheticPlugin.shortName + ".jpi"), targetJenkinsVersion);
}
System.out.println("Will load plugins: " + Stream.of(plugins.list()).filter(n -> n.matches(".+[.][hj]p[il]")).sorted().collect(Collectors.joining(" ")));
}

Expand Down Expand Up @@ -1617,4 +1652,122 @@ private static class OutputPayload implements Serializable {
assumptionFailure = error instanceof AssumptionViolatedException ? error.getMessage() : null;
}
}

/**
* Alternative to {@link #addPlugins} or {@link TestExtension} that lets you build a test-only plugin on the fly.
* ({@link ExtensionList#add(Object)} can also be used for certain cases, but not if you need to define new types.)
*/
public final class SyntheticPlugin {
private final String pkg;
private String shortName;
private String version = "1-SNAPSHOT";
private Map<String, String> headers = new HashMap<>();

SyntheticPlugin(String pkg) {
this.pkg = pkg;
shortName = "synthetic-" + pkg.replace('.', '-');
}

/**
* Plugin identifier ({@code Short-Name} manifest header).
* Defaults to being calculated from the package name,
* replacing {@code .} with {@code -} and prefixed by {@code synthetic-}.
*/
public SyntheticPlugin shortName(String shortName) {
this.shortName = shortName;
return this;
}

/**
* Plugin version string ({@code Plugin-Version} manifest header).
* Defaults to an arbitrary snapshot version.
*/
public SyntheticPlugin version(String version) {
this.version = version;
return this;
}

/**
* Add an extra plugin manifest header.
* Examples:
* <ul>
* <li>{@code Jenkins-Version: 2.387.3}
* <li>{@code Plugin-Dependencies: structs:325.vcb_307d2a_2782,support-core:1356.vd0f980edfa_46;resolution:=optional}
* <li>{@code Long-Name: My Plugin}
* </ul>
*/
public SyntheticPlugin header(String key, String value) {
headers.put(key, value);
return this;
}

/**
* @return back to the rule builder
*/
public RealJenkinsRule done() {
return RealJenkinsRule.this;
}

void writeTo(File jpi, String defaultJenkinsVersion) throws IOException, URISyntaxException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
String pkgSlash = pkg.replace('.', '/');
URL mainU = RealJenkinsRule.class.getClassLoader().getResource(pkgSlash);
if (mainU == null) {
throw new IOException("Cannot find " + pkgSlash + " in classpath");
}
Path main = Path.of(mainU.toURI());
if (!Files.isDirectory(main)) {
throw new IOException(main + " does not exist");
}
Path metaInf = Path.of(URI.create(mainU.toString().replaceFirst("\\Q" + pkgSlash + "\\E/?$", "META-INF")));
if (Files.isDirectory(metaInf)) {
zip(zos, metaInf, "META-INF/", pkg);
}
zip(zos, main, pkgSlash + "/", null);
}
Manifest mani = new Manifest();
Attributes attr = mani.getMainAttributes();
attr.put(Attributes.Name.MANIFEST_VERSION, "1.0");
attr.putValue("Short-Name", shortName);
attr.putValue("Plugin-Version", version);
attr.putValue("Jenkins-Version", defaultJenkinsVersion);
for (Map.Entry<String, String> entry : headers.entrySet()) {
attr.putValue(entry.getKey(), entry.getValue());
}
try (OutputStream os = new FileOutputStream(jpi); JarOutputStream jos = new JarOutputStream(os, mani)) {
jos.putNextEntry(new JarEntry("WEB-INF/lib/" + shortName + ".jar"));
jos.write(baos.toByteArray());
}
LOGGER.info(() -> "Generated " + jpi);
}

private void zip(ZipOutputStream zos, Path dir, String prefix, @CheckForNull String filter) throws IOException {
try (Stream<Path> stream = Files.list(dir)) {
Iterable<Path> iterable = stream::iterator;
for (Path child : iterable) {
Path nameP = child.getFileName();
assert nameP != null;
String name = nameP.toString();
if (Files.isDirectory(child)) {
zip(zos, child, prefix + name + "/", filter);
} else {
if (filter != null) {
// Deliberately not using UTF-8 since the file could be binary.
// If the package name happened to be non-ASCII, 🤷 this could be improved.
if (!Files.readString(child, StandardCharsets.ISO_8859_1).contains(filter)) {
LOGGER.info(() -> "Skipping " + child + " since it makes no mention of " + filter);
continue;
}
}
LOGGER.info(() -> "Packing " + child);
zos.putNextEntry(new ZipEntry(prefix + name));
Files.copy(child, zos);
}
}
}
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* The MIT License
*
* Copyright 2024 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

package org.jvnet.hudson.test;

import jenkins.model.Jenkins;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.sample.plugin.Stuff;

public final class RealJenkinsRuleSyntheticPluginTest {

@Rule public RealJenkinsRule rr = new RealJenkinsRule().prepareHomeLazily(true);

@Test public void smokes() throws Throwable {
rr.addSyntheticPlugin(Stuff.class.getPackage()).done();
rr.then(RealJenkinsRuleSyntheticPluginTest::_smokes);
}

private static void _smokes(JenkinsRule r) throws Throwable {
assertThat(r.createWebClient().goTo("stuff", "text/plain").getWebResponse().getContentAsString(),
is(Jenkins.get().getLegacyInstanceId()));
}

}
50 changes: 50 additions & 0 deletions src/test/java/org/jvnet/hudson/test/sample/plugin/Stuff.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* The MIT License
*
* Copyright 2024 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

package org.jvnet.hudson.test.sample.plugin;

import hudson.Extension;
import hudson.model.InvisibleAction;
import hudson.model.UnprotectedRootAction;
import jenkins.model.Jenkins;
import org.jvnet.hudson.test.RealJenkinsRuleSyntheticPluginTest;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;

/**
* Sample extension.
* @see RealJenkinsRuleSyntheticPluginTest
*/
@Extension
public final class Stuff extends InvisibleAction implements UnprotectedRootAction {
@Override
public String getUrlName() {
return "stuff";
}

public HttpResponse doIndex() {
return HttpResponses.text(Jenkins.get().getLegacyInstanceId());
}

}

0 comments on commit 13b7b3d

Please sign in to comment.