diff --git a/src/main/java/org/jvnet/hudson/test/DeltaSupportLogFormatter.java b/src/main/java/org/jvnet/hudson/test/DeltaSupportLogFormatter.java index 928d03ddd..5c0b19358 100644 --- a/src/main/java/org/jvnet/hudson/test/DeltaSupportLogFormatter.java +++ b/src/main/java/org/jvnet/hudson/test/DeltaSupportLogFormatter.java @@ -27,11 +27,11 @@ import io.jenkins.lib.support_log_formatter.SupportLogFormatter; import java.util.logging.LogRecord; -class DeltaSupportLogFormatter extends SupportLogFormatter { +public class DeltaSupportLogFormatter extends SupportLogFormatter { static long start = System.currentTimeMillis(); - static String elapsedTime() { + public static String elapsedTime() { return String.format("%8.3f", (System.currentTimeMillis() - start) / 1_000.0); } diff --git a/src/main/java/org/jvnet/hudson/test/HudsonHomeLoader.java b/src/main/java/org/jvnet/hudson/test/HudsonHomeLoader.java index 4e83d8474..3404ab7c9 100644 --- a/src/main/java/org/jvnet/hudson/test/HudsonHomeLoader.java +++ b/src/main/java/org/jvnet/hudson/test/HudsonHomeLoader.java @@ -121,7 +121,7 @@ public File allocate() throws Exception { return target; } - void copy(File target) throws Exception { + public void copy(File target) throws Exception { URL res = findDataResource(); if (!res.getProtocol().equals("file")) { throw new AssertionError("Test data is not available in the file system: " + res); diff --git a/src/main/java/org/jvnet/hudson/test/JenkinsRule.java b/src/main/java/org/jvnet/hudson/test/JenkinsRule.java index c03c8d739..f5d503eb6 100644 --- a/src/main/java/org/jvnet/hudson/test/JenkinsRule.java +++ b/src/main/java/org/jvnet/hudson/test/JenkinsRule.java @@ -446,7 +446,7 @@ public static void _configureJenkinsForTest(Jenkins jenkins) throws Exception { jenkins.getJDKs().add(new JDK("default", System.getProperty("java.home"))); } - static void dumpThreads() { + public static void dumpThreads() { ThreadInfo[] threadInfos = Functions.getThreadInfos(); Functions.ThreadGroupMap m = Functions.sortThreadsAndGetGroupMap(threadInfos); for (ThreadInfo ti : threadInfos) { @@ -1172,6 +1172,13 @@ public void showAgentLogs(Slave s, LoggerRule loggerRule) throws Exception { showAgentLogs(s, loggerRule.getRecordedLevels()); } + /** + * Same as {@link #showAgentLogs(Slave, Map)} but taking a preconfigured list of loggers as a convenience. + */ + public void showAgentLogs(Slave s, LogRecorder logRecorder) throws Exception { + showAgentLogs(s, logRecorder.getRecordedLevels()); + } + /** * Forward agent logs to standard error of the test process. * Otherwise log messages would be sent only to {@link Computer#getLogText} etc., @@ -1183,7 +1190,7 @@ public void showAgentLogs(Slave s, Map loggers) throws Exception s.getChannel().call(new RemoteLogDumper(s.getNodeName(), loggers, true)); } - static final class RemoteLogDumper extends MasterToSlaveCallable { + public static final class RemoteLogDumper extends MasterToSlaveCallable { private final String name; private final Map loggers; private final TaskListener stderr; @@ -1192,7 +1199,7 @@ static final class RemoteLogDumper extends MasterToSlaveCallable loggerReferences = new LinkedList<>(); - RemoteLogDumper(String name, Map loggers, boolean forward) { + public RemoteLogDumper(String name, Map loggers, boolean forward) { this.name = name; this.loggers = loggers; stderr = forward ? StreamTaskListener.fromStderr() : null; diff --git a/src/main/java/org/jvnet/hudson/test/PluginUtils.java b/src/main/java/org/jvnet/hudson/test/PluginUtils.java index f4b5a523a..2dc40fb2b 100644 --- a/src/main/java/org/jvnet/hudson/test/PluginUtils.java +++ b/src/main/java/org/jvnet/hudson/test/PluginUtils.java @@ -13,7 +13,18 @@ import java.util.jar.JarOutputStream; import java.util.jar.Manifest; -class PluginUtils { +public class PluginUtils { + + /** + * Creates the plugin used by RealJenkinsExtension + * @param destinationDirectory directory to write the plugin to. + * @param baseline the version of Jenkins to target + * @throws IOException if something goes wrong whilst creating the plugin. + * @return File the plugin we just created + */ + public static File createRealJenkinsExtensionPlugin(File destinationDirectory, String baseline) throws IOException { + return createRealJenkinsPlugin("RealJenkinsExtension", destinationDirectory, baseline); + } /** * Creates the plugin used by RealJenkinsRule @@ -22,23 +33,28 @@ class PluginUtils { * @throws IOException if something goes wrong whilst creating the plugin. * @return File the plugin we just created */ + static File createRealJenkinsRulePlugin(File destinationDirectory, String baseline) throws IOException { + return createRealJenkinsPlugin("RealJenkinsRule", destinationDirectory, baseline); + } + @SuppressFBWarnings( value = "PATH_TRAVERSAL_IN", justification = "jth is a test utility, this is package scope code") - static File createRealJenkinsRulePlugin(File destinationDirectory, String baseline) throws IOException { - final String pluginName = RealJenkinsRuleInit.class.getSimpleName(); + static File createRealJenkinsPlugin(String target, File destinationDirectory, String baseline) throws IOException { + Class pluginClass = RealJenkinsRuleInit.class; // The manifest is reused in the plugin and the classes jar. Manifest mf = new Manifest(); Attributes mainAttributes = mf.getMainAttributes(); mainAttributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); - mainAttributes.putValue("Plugin-Class", RealJenkinsRuleInit.class.getName()); - mainAttributes.putValue("Extension-Name", pluginName); - mainAttributes.putValue("Short-Name", pluginName); - mainAttributes.putValue("Long-Name", "RealJenkinsRule initialization wrapper"); - mainAttributes.putValue("Plugin-Version", "0-SNAPSHOT (private rjr)"); + mainAttributes.putValue("Plugin-Class", pluginClass.getName()); + mainAttributes.putValue("Extension-Name", pluginClass.getSimpleName()); + mainAttributes.putValue("Short-Name", pluginClass.getSimpleName()); + mainAttributes.putValue("Long-Name", target + " initialization wrapper"); + mainAttributes.putValue("Plugin-Version", "0-SNAPSHOT (private rj)"); mainAttributes.putValue("Support-Dynamic-Loading", "true"); mainAttributes.putValue("Jenkins-Version", baseline); + mainAttributes.putValue("Init-Target", target); // we need to create a jar for the classes which we can then put into the plugin. Path tmpClassesJar = Files.createTempFile("rjr", "jar"); @@ -46,20 +62,18 @@ static File createRealJenkinsRulePlugin(File destinationDirectory, String baseli try (FileOutputStream fos = new FileOutputStream(tmpClassesJar.toFile()); JarOutputStream classesJarOS = new JarOutputStream(fos, mf)) { // the actual class - try (InputStream classIS = RealJenkinsRuleInit.class.getResourceAsStream( - RealJenkinsRuleInit.class.getSimpleName() + ".class")) { - String path = RealJenkinsRuleInit.class.getPackageName().replace('.', '/'); - createJarEntry( - classesJarOS, path + '/' + RealJenkinsRuleInit.class.getSimpleName() + ".class", classIS); + try (InputStream classIS = pluginClass.getResourceAsStream(pluginClass.getSimpleName() + ".class")) { + String path = pluginClass.getPackageName().replace('.', '/'); + createJarEntry(classesJarOS, path + '/' + pluginClass.getSimpleName() + ".class", classIS); } } // the actual JPI - File jpi = new File(destinationDirectory, pluginName + ".jpi"); + File jpi = new File(destinationDirectory, pluginClass.getSimpleName() + ".jpi"); try (FileOutputStream fos = new FileOutputStream(jpi); JarOutputStream jos = new JarOutputStream(fos, mf)) { try (FileInputStream fis = new FileInputStream(tmpClassesJar.toFile())) { - createJarEntry(jos, "WEB-INF/lib/" + pluginName + ".jar", fis); + createJarEntry(jos, "WEB-INF/lib/" + pluginClass.getSimpleName() + ".jar", fis); } } return jpi; diff --git a/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java b/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java index d1e9d4ac4..3783aa800 100644 --- a/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java +++ b/src/main/java/org/jvnet/hudson/test/RealJenkinsRule.java @@ -55,7 +55,6 @@ import java.net.HttpURLConnection; import java.net.InetAddress; import java.net.MalformedURLException; -import java.net.SocketException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; @@ -1251,7 +1250,7 @@ public void stopJenkins() throws Throwable { decorateConnection(endpoint("exit").openConnection()) .getInputStream() .close(); - } catch (SocketException e) { + } catch (IOException e) { System.err.println("Unable to connect to the Jenkins process to stop it: " + e); } } else { diff --git a/src/main/java/org/jvnet/hudson/test/RealJenkinsRuleInit.java b/src/main/java/org/jvnet/hudson/test/RealJenkinsRuleInit.java index bd9189e36..390a51c96 100644 --- a/src/main/java/org/jvnet/hudson/test/RealJenkinsRuleInit.java +++ b/src/main/java/org/jvnet/hudson/test/RealJenkinsRuleInit.java @@ -24,9 +24,12 @@ package org.jvnet.hudson.test; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.Plugin; +import java.io.File; import java.net.URL; import java.net.URLClassLoader; +import java.util.jar.Manifest; import jenkins.model.Jenkins; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; @@ -34,24 +37,39 @@ /** * Plugin for use internally only by {@link RealJenkinsRule}, do not use this from plugin test code! *

- * NOTE: this and only this class is added into a dynamically generated plugin, see {@link PluginUtils#createRealJenkinsRulePlugin(java.io.File, String)}. + * NOTE: this and only this class is added into a dynamically generated plugin, see {@link PluginUtils#createRealJenkinsPlugin(String, File, String)}. * In order for this to occur correctly there need to be no inner classes or other code dependencies here (except what can be loaded by reflection). */ @Restricted(NoExternalUse.class) public class RealJenkinsRuleInit extends Plugin { - @SuppressWarnings( - "deprecation") // @Initializer just gets run too late, even with before = InitMilestone.PLUGINS_PREPARED + @SuppressWarnings("deprecation") + // @Initializer just gets run too late, even with before = InitMilestone.PLUGINS_PREPARED public RealJenkinsRuleInit() {} @Override + @SuppressFBWarnings(value = "URLCONNECTION_SSRF_FD", justification = "jth is a test utility") public void start() throws Exception { - new URLClassLoader( - "RealJenkinsRule", - new URL[] {new URL(System.getProperty("RealJenkinsRule.location"))}, - ClassLoader.getSystemClassLoader().getParent()) - .loadClass("org.jvnet.hudson.test.RealJenkinsRule$Init2") - .getMethod("run", Object.class) - .invoke(null, Jenkins.get()); + URL url = ((URLClassLoader) getClass().getClassLoader()).findResource("META-INF/MANIFEST.MF"); + Manifest manifest = new Manifest(url.openStream()); + String target = manifest.getMainAttributes().getValue("Init-Target"); + + if ("RealJenkinsRule".equals(target)) { + new URLClassLoader( + "RealJenkinsRule", + new URL[] {new URL(System.getProperty("RealJenkinsRule.location"))}, + ClassLoader.getSystemClassLoader().getParent()) + .loadClass("org.jvnet.hudson.test.RealJenkinsRule$Init2") + .getMethod("run", Object.class) + .invoke(null, Jenkins.get()); + } else if ("RealJenkinsExtension".equals(target)) { + new URLClassLoader( + "RealJenkinsExtension", + new URL[] {new URL(System.getProperty("RealJenkinsExtension.location"))}, + ClassLoader.getSystemClassLoader().getParent()) + .loadClass("org.jvnet.hudson.test.junit.jupiter.RealJenkinsExtension$Init2") + .getMethod("run", Object.class) + .invoke(null, Jenkins.get()); + } } } diff --git a/src/main/java/org/jvnet/hudson/test/TailLog.java b/src/main/java/org/jvnet/hudson/test/TailLog.java index 5220beff2..1a44ae379 100644 --- a/src/main/java/org/jvnet/hudson/test/TailLog.java +++ b/src/main/java/org/jvnet/hudson/test/TailLog.java @@ -42,6 +42,7 @@ import java.util.concurrent.TimeUnit; import org.apache.commons.io.input.Tailer; import org.apache.commons.io.input.TailerListenerAdapter; +import org.jvnet.hudson.test.junit.jupiter.RealJenkinsExtension; import org.jvnet.hudson.test.recipes.LocalData; /** @@ -81,6 +82,15 @@ public TailLog(RealJenkinsRule rjr, String job, int number) { this(runRootDir(rjr.getHome(), job, number), job, number); } + /** + * Watch a build expected to be loaded in a controller JVM. + * Note: this constructor will not work for a branch project (child of {@code MultiBranchProject}). + * @param job a {@link Job#getFullName} + */ + public TailLog(RealJenkinsExtension rje, String job, int number) { + this(runRootDir(rje.getHome(), job, number), job, number); + } + private static File runRootDir(File home, String job, int number) { // For MultiBranchProject the last segment would be "branches" not "jobs": return new File(home, "jobs/" + job.replace("/", "/jobs/") + "/builds/" + number); diff --git a/src/main/java/org/jvnet/hudson/test/TemporaryDirectoryAllocator.java b/src/main/java/org/jvnet/hudson/test/TemporaryDirectoryAllocator.java index cdc1685a4..07ce047fa 100644 --- a/src/main/java/org/jvnet/hudson/test/TemporaryDirectoryAllocator.java +++ b/src/main/java/org/jvnet/hudson/test/TemporaryDirectoryAllocator.java @@ -85,7 +85,7 @@ public File allocate() throws IOException { return allocate(withoutSpace ? "jkh" : "j h"); } - synchronized File allocate(String name) throws IOException { + public synchronized File allocate(String name) throws IOException { try { File f = Files.createTempDirectory(base.toPath(), name).toFile(); tmpDirectories.add(f); diff --git a/src/main/java/org/jvnet/hudson/test/WarExploder.java b/src/main/java/org/jvnet/hudson/test/WarExploder.java index d0d617662..5d7672dec 100644 --- a/src/main/java/org/jvnet/hudson/test/WarExploder.java +++ b/src/main/java/org/jvnet/hudson/test/WarExploder.java @@ -71,7 +71,7 @@ public static synchronized File getExplodedDir() throws Exception { private static File EXPLODE_DIR; - static File findJenkinsWar() throws Exception { + public static File findJenkinsWar() throws Exception { File war; if (JENKINS_WAR_PATH != null) { war = new File(JENKINS_WAR_PATH).getAbsoluteFile(); diff --git a/src/main/java/org/jvnet/hudson/test/junit/jupiter/BuildWatcherExtension.java b/src/main/java/org/jvnet/hudson/test/junit/jupiter/BuildWatcherExtension.java new file mode 100644 index 000000000..882493064 --- /dev/null +++ b/src/main/java/org/jvnet/hudson/test/junit/jupiter/BuildWatcherExtension.java @@ -0,0 +1,184 @@ +/* + * The MIT License + * + * Copyright 2015 Jesse Glick. + * + * 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.junit.jupiter; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.Extension; +import hudson.console.LineTransformationOutputStream; +import hudson.model.Run; +import hudson.model.TaskListener; +import hudson.model.listeners.RunListener; +import java.io.*; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import jenkins.model.Jenkins; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.DeltaSupportLogFormatter; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TailLog; + +/** + * Echoes build output to standard error as it arrives. + * Usage:

{@code
+ * @RegisterExtension
+ * private static final BuildWatcherExtension buildWatcher = new BuildWatcherExtension();
+ * }
+ * Should work in combination with {@link JenkinsRule} or {@link JenkinsSessionExtension}. + *

+ * This is the JUnit5 implementation of {@link BuildWatcher}. + * + * @see JenkinsRule#waitForCompletion + * @see JenkinsRule#waitForMessage + * @see TailLog + * @see BuildWatcher + */ +public final class BuildWatcherExtension implements BeforeAllCallback, AfterAllCallback { + + private static boolean active; + private static final Map builds = new ConcurrentHashMap<>(); + + private Thread thread; + + @Override + @SuppressFBWarnings(value = "ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") + public void beforeAll(ExtensionContext extensionContext) throws Exception { + active = true; + thread = new Thread("watching builds") { + @Override + public void run() { + try { + while (active) { + for (RunningBuild build : builds.values()) { + build.copy(); + } + Thread.sleep(50); + } + } catch (InterruptedException x) { + // stopped + } + // last chance + for (RunningBuild build : builds.values()) { + build.copy(); + } + } + }; + thread.setDaemon(true); + thread.start(); + } + + @Override + @SuppressFBWarnings(value = "ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD") + public void afterAll(ExtensionContext extensionContext) throws Exception { + active = false; + thread.interrupt(); + } + + @Extension + public static final class Listener extends RunListener> { + + @Override + public void onStarted(Run r, TaskListener listener) { + if (!active) { + return; + } + RunningBuild build = new RunningBuild(r); + RunningBuild orig = builds.put(r.getRootDir(), build); + if (orig != null) { + System.err.println(r + " was started twice?!"); + } + } + + @Override + public void onFinalized(Run r) { + if (!active) { + return; + } + RunningBuild build = builds.remove(r.getRootDir()); + if (build != null) { + build.copy(); + } else { + System.err.println( + r + " was finalized but never started; assuming it was started earlier using @LocalData"); + new RunningBuild(r).copy(); + } + } + } + + private static final class RunningBuild { + + private final Run r; + private final OutputStream sink; + private long pos; + + RunningBuild(Run r) { + this.r = r; + sink = new LogLinePrefixOutputFilter(System.err, "[" + r + "] "); + } + + synchronized void copy() { + try { + pos = r.getLogText().writeLogTo(pos, sink); + // Note that !log.isComplete() after the initial call to copy, even if the build is complete, because + // Run.getLogText never calls markComplete! + // That is why Run.writeWholeLogTo calls getLogText repeatedly. + // Even if it did call markComplete this might not work from RestartableJenkinsRule since you would have + // a different Run object after the restart. + // Anyway we can just rely on onFinalized to let us know when to stop. + } catch (FileNotFoundException x) { + // build deleted or not started + } catch (Throwable x) { + if (Jenkins.getInstanceOrNull() != null) { + x.printStackTrace(); + } else { + // probably just IllegalStateException: Jenkins.instance is missing, AssertionError: class … is + // missing its descriptor, etc. + } + } + } + } + + // Copied from WorkflowRun. + private static final class LogLinePrefixOutputFilter extends LineTransformationOutputStream { + + private final PrintStream logger; + private final String prefix; + + LogLinePrefixOutputFilter(PrintStream logger, String prefix) { + this.logger = logger; + this.prefix = prefix; + } + + @Override + protected void eol(byte[] b, int len) throws IOException { + logger.append(DeltaSupportLogFormatter.elapsedTime()); + logger.write(' '); + logger.append(prefix); + logger.write(b, 0, len); + } + } +} diff --git a/src/main/java/org/jvnet/hudson/test/junit/jupiter/InboundAgentExtension.java b/src/main/java/org/jvnet/hudson/test/junit/jupiter/InboundAgentExtension.java new file mode 100644 index 000000000..9b11ca19b --- /dev/null +++ b/src/main/java/org/jvnet/hudson/test/junit/jupiter/InboundAgentExtension.java @@ -0,0 +1,680 @@ +/* + * The MIT License + * + * Copyright 2022 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.junit.jupiter; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.model.Computer; +import hudson.model.Descriptor; +import hudson.model.Node; +import hudson.model.Slave; +import hudson.remoting.VirtualChannel; +import hudson.slaves.DumbSlave; +import hudson.slaves.JNLPLauncher; +import hudson.slaves.RetentionStrategy; +import hudson.slaves.SlaveComputer; +import hudson.util.ProcessTree; +import hudson.util.StreamCopyThread; +import hudson.util.VersionNumber; +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.jar.JarFile; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import org.apache.commons.io.FileUtils; +import org.apache.tools.ant.util.JavaEnvUtils; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.PrefixedOutputStream; + +/** + * Manages inbound agents. + * While these run on the local host, they are launched outside of Jenkins. + * + *

To avoid flakiness when tearing down the test, ensure that the agent has gone offline with: + * + *

+ * Slave agent = inboundAgents.createAgent(r, […]);
+ * try {
+ *     […]
+ * } finally {
+ *     inboundAgents.stop(r, agent.getNodeName());
+ * }
+ * 
+ * + * @see JenkinsRule#createComputerLauncher + * @see JenkinsRule#createSlave() + */ +public class InboundAgentExtension implements AfterEachCallback { + + private static final Logger LOGGER = Logger.getLogger(InboundAgentExtension.class.getName()); + + private final String id = UUID.randomUUID().toString(); + private final Map> procs = Collections.synchronizedMap(new HashMap<>()); + private final Set workDirs = Collections.synchronizedSet(new HashSet<>()); + private final Set jars = Collections.synchronizedSet(new HashSet<>()); + + /** + * The options used to (re)start an inbound agent. + */ + public static final class Options implements Serializable { + + @CheckForNull + private String name; + + private boolean webSocket; + + @CheckForNull + private String tunnel; + + private List javaOptions = new ArrayList<>(); + private boolean start = true; + private final LinkedHashMap loggers = new LinkedHashMap<>(); + private String label; + private final PrefixedOutputStream.Builder prefixedOutputStreamBuilder = PrefixedOutputStream.builder(); + private String trustStorePath; + private String trustStorePassword; + private String cert; + private boolean noCertificateCheck; + + public String getName() { + return name; + } + + public boolean isWebSocket() { + return webSocket; + } + + public String getTunnel() { + return tunnel; + } + + public boolean isStart() { + return start; + } + + public String getLabel() { + return label; + } + + /** + * Compute java options required to connect to the given RealJenkinsExtension instance. + * If {@link #cert} or {@link #noCertificateCheck} is set, trustStore options are not computed. + * This prevents Remoting from implicitly bypassing failures related to {@code -cert} or {@code -noCertificateCheck}. + * + * @param r The instance to compute Java options for + */ + private void computeJavaOptions(RealJenkinsExtension r) { + if (cert != null || noCertificateCheck) { + return; + } + if (trustStorePath != null && trustStorePassword != null) { + javaOptions.addAll(List.of( + "-Djavax.net.ssl.trustStore=" + trustStorePath, + "-Djavax.net.ssl.trustStorePassword=" + trustStorePassword)); + } else { + javaOptions.addAll(List.of(r.getTruststoreJavaOptions())); + } + } + + /** + * A builder of {@link Options}. + * + *

Instances of {@link Builder} are created by calling {@link + * InboundAgentExtension.Options#newBuilder}. + */ + public static final class Builder { + + private final Options options = new Options(); + + private Builder() {} + + /** + * Set the name of the agent. + * + * @param name the name + * @return this builder + */ + public Builder name(String name) { + options.name = name; + return this; + } + + /** + * Set a color for agent logs. + * + * @param color the color + * @return this builder + */ + public Builder color(PrefixedOutputStream.AnsiColor color) { + options.prefixedOutputStreamBuilder.withColor(color); + return this; + } + + /** + * Use WebSocket when connecting. + * + * @return this builder + */ + public Builder webSocket() { + return webSocket(true); + } + + /** + * Configure usage of WebSocket when connecting. + * + * @param websocket use websocket if true, otherwise use inbound TCP + * @return this builder + */ + public Builder webSocket(boolean websocket) { + options.webSocket = websocket; + return this; + } + + /** + * Set a tunnel for the agent + * + * @return this builder + */ + public Builder tunnel(String tunnel) { + options.tunnel = tunnel; + return this; + } + + public Builder javaOptions(String... opts) { + options.javaOptions.addAll(List.of(opts)); + return this; + } + + /** + * Provide a custom truststore for the agent JVM. Can be useful when using a setup with a reverse proxy. + * + * @param path the path to the truststore + * @param password the password for the truststore + * @return this builder + */ + public Builder trustStore(String path, String password) { + options.trustStorePath = path; + options.trustStorePassword = password; + return this; + } + + /** + * Sets a custom certificate for the agent JVM, passed as the Remoting `-cert` CLI argument. + * When using {@code RealJenkinsExtension}, use {@link RealJenkinsExtension#getRootCAPem()} to obtain the required value to pass to this method. + * + * @param cert the certificate to use + * @return this builder + */ + public Builder cert(String cert) { + options.cert = cert; + return this; + } + + /** + * Disables certificate verification for the agent JVM, passed as the Remoting `-noCertificateCheck` CLI argument. + * + * @return this builder + */ + public Builder noCertificateCheck() { + options.noCertificateCheck = true; + return this; + } + + /** + * Skip starting the agent. + * + * @return this builder + */ + public Builder skipStart() { + options.start = false; + return this; + } + + /** + * Set a label for the agent. + * + * @return this builder. + */ + public Builder label(String label) { + options.label = label; + return this; + } + + public Builder withLogger(Class clazz, Level level) { + return withLogger(clazz.getName(), level); + } + + public Builder withPackageLogger(Class clazz, Level level) { + return withLogger(clazz.getPackageName(), level); + } + + public Builder withLogger(String logger, Level level) { + options.loggers.put(logger, level); + return this; + } + + /** + * Build and return an {@link Options}. + * + * @return a new {@link Options} + */ + public Options build() { + return options; + } + } + + public static Builder newBuilder() { + return new Builder(); + } + } + + /** + * Creates, attaches, and starts a new inbound agent. + * + * @param name an optional {@link Slave#getNodeName} + */ + public Slave createAgent(@NonNull JenkinsRule r, @CheckForNull String name) throws Exception { + return createAgent(r, Options.newBuilder().name(name).build()); + } + + /** + * Creates, attaches, and optionally starts a new inbound agent. + * + * @param options the options + */ + public Slave createAgent(@NonNull JenkinsRule r, Options options) throws Exception { + Slave s = createAgentJR(r, options); + workDirs.add(s.getRemoteFS()); + if (options.isStart()) { + start(r, options); + } + return s; + } + + public void createAgent(@NonNull RealJenkinsExtension extension, @CheckForNull String name) throws Throwable { + createAgent(extension, Options.newBuilder().name(name).build()); + } + + public void createAgent(@NonNull RealJenkinsExtension extension, Options options) throws Throwable { + var nameAndWorkDir = extension.runRemotely(InboundAgentExtension::createAgentRJR, options); + options.name = nameAndWorkDir[0]; + workDirs.add(nameAndWorkDir[1]); + if (options.isStart()) { + start(extension, options); + } + } + + /** + * (Re-)starts an existing inbound agent. + */ + public void start(@NonNull JenkinsRule r, @NonNull String name) throws Exception { + start(r, Options.newBuilder().name(name).build()); + } + + /** + * (Re-)starts an existing inbound agent. + */ + public void start(@NonNull JenkinsRule r, Options options) throws Exception { + String name = options.getName(); + Objects.requireNonNull(name); + stop(r, name); + var args = getAgentArguments(r, name); + jars.add(args.agentJar); + start(args, options); + waitForAgentOnline(r, name, options.loggers); + } + + /** + * (Re-)starts an existing inbound agent. + */ + public void start(@NonNull RealJenkinsExtension r, Options options) throws Throwable { + String name = options.getName(); + Objects.requireNonNull(name); + stop(r, name); + startOnly(r, options); + r.runRemotely(InboundAgentExtension::waitForAgentOnline, name, options.loggers); + } + + /** + * Starts an existing inbound agent without waiting for it to go online. + */ + public void startOnly(@NonNull RealJenkinsExtension extension, Options options) throws Throwable { + Objects.requireNonNull(options.getName()); + var args = agentArguments(extension, options); + options.computeJavaOptions(extension); + start(args, options, false); + } + + private AgentArguments agentArguments(RealJenkinsExtension extension, Options options) throws Throwable { + var args = extension.runRemotely(InboundAgentExtension::getAgentArguments, options.getName()); + jars.add(args.agentJar); + return args; + } + + public void start(AgentArguments agentArguments, Options options) throws Exception { + start(agentArguments, options, true); + } + + @SuppressFBWarnings(value = "COMMAND_INJECTION", justification = "just for test code") + private void start(AgentArguments agentArguments, Options options, boolean stop) + throws InterruptedException, IOException { + Objects.requireNonNull(options.getName()); + if (stop) { + stop(options.getName()); + } + List cmd = new ArrayList<>(List.of( + JavaEnvUtils.getJreExecutable("java"), + "-Xmx512m", + "-XX:+PrintCommandLineFlags", + "-Djava.awt.headless=true")); + if (JenkinsRule.SLAVE_DEBUG_PORT > 0) { + cmd.add("-Xdebug"); + cmd.add("Xrunjdwp:transport=dt_socket,server=y,address=" + + (JenkinsRule.SLAVE_DEBUG_PORT + agentArguments.numberOfNodes - 1)); + } + cmd.addAll(options.javaOptions); + cmd.addAll(List.of("-jar", agentArguments.agentJar.getAbsolutePath())); + if (remotingVersion(agentArguments.agentJar).isNewerThanOrEqualTo(new VersionNumber("3186.vc3b_7249b_87eb_"))) { + cmd.addAll(List.of("-url", agentArguments.url)); + cmd.addAll(List.of("-name", agentArguments.name)); + cmd.addAll(List.of("-secret", agentArguments.secret)); + if (options.isWebSocket()) { + cmd.add("-webSocket"); + } + if (options.getTunnel() != null) { + cmd.addAll(List.of("-tunnel", options.getTunnel())); + } + } else { + cmd.addAll(List.of("-jnlpUrl", agentArguments.agentJnlpUrl())); + } + + if (options.noCertificateCheck) { + cmd.add("-noCertificateCheck"); + } else if (options.cert != null) { + cmd.addAll(List.of("-cert", options.cert)); + } + + cmd.addAll(agentArguments.commandLineArgs); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + pb.environment().put("INBOUND_AGENT_RULE_ID", id); + pb.environment().put("INBOUND_AGENT_RULE_NAME", options.getName()); + LOGGER.info(() -> "Running: " + pb.command()); + Process proc = pb.start(); + procs.merge(options.getName(), List.of(proc), (oldValue, newValue) -> { + // Duplicate agent name, but this can be a valid test case. + List result = new ArrayList<>(oldValue); + result.addAll(newValue); + return result; + }); + new StreamCopyThread( + "inbound-agent-" + options.getName(), + proc.getInputStream(), + options.prefixedOutputStreamBuilder.build(System.err)) + .start(); + } + + private static VersionNumber remotingVersion(File agentJar) throws IOException { + try (JarFile j = new JarFile(agentJar)) { + String v = j.getManifest().getMainAttributes().getValue("Version"); + if (v == null) { + throw new IOException("no Version in " + agentJar); + } + return new VersionNumber(v); + } + } + + /** + * Stop an existing inbound agent and wait for it to go offline. + */ + public void stop(@NonNull JenkinsRule r, @NonNull String name) throws InterruptedException { + stop(name); + waitForAgentOffline(r, name); + } + + /** + * Stop an existing inbound agent and wait for it to go offline. + */ + public void stop(@NonNull RealJenkinsExtension rjr, @NonNull String name) throws Throwable { + stop(name); + if (rjr.isAlive()) { + rjr.runRemotely(InboundAgentExtension::waitForAgentOffline, name); + } else { + LOGGER.warning( + () -> "Controller seems to have already shut down; not waiting for " + name + " to go offline"); + } + } + + /** + * Stops an existing inbound agent. + * You need only call this to simulate an agent crash, followed by {@link #start}. + */ + public void stop(@NonNull String name) { + procs.computeIfPresent(name, (k, v) -> { + stop(name, v); + return null; + }); + } + + private static void stop(String name, List v) { + for (Process proc : v) { + LOGGER.info(() -> "Killing " + name + " agent JVM (but not subprocesses)"); + proc.destroyForcibly(); + try { + proc.waitFor(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for process to terminate", e); + } + } + } + + /** + * Checks whether an existing inbound agent process is currently running. + * (This is distinct from whether Jenkins considers the computer to be connected.) + */ + public boolean isAlive(String name) { + return procs.get(name).stream().anyMatch(Process::isAlive); + } + + @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "test code") + @Override + public void afterEach(ExtensionContext context) { + for (var entry : procs.entrySet()) { + String name = entry.getKey(); + stop(name, entry.getValue()); + try { + LOGGER.info(() -> "Cleaning up " + name + " agent JVM and/or any subprocesses"); + ProcessTree.get().killAll(null, Map.of("INBOUND_AGENT_RULE_ID", id, "INBOUND_AGENT_RULE_NAME", name)); + } catch (InterruptedException e) { + e.printStackTrace(); + Thread.currentThread().interrupt(); + } + } + procs.clear(); + for (var workDir : workDirs) { + LOGGER.info(() -> "Deleting " + workDir); + try { + FileUtils.deleteDirectory(new File(workDir)); + } catch (IOException x) { + LOGGER.log(Level.WARNING, null, x); + } + } + for (var jar : jars) { + LOGGER.info(() -> "Deleting " + jar); + try { + Files.deleteIfExists(jar.toPath()); + } catch (IOException x) { + LOGGER.log(Level.WARNING, null, x); + } + } + } + + /** + * @param agentJar A reference to the agent jar + * @param url the controller root URL + * @param name the agent name + * @param secret The secret the agent should use to connect. + * @param numberOfNodes The number of nodes in the Jenkins instance where the agent is running. + * @param commandLineArgs Additional command line arguments to pass to the agent. + */ + public record AgentArguments( + @NonNull File agentJar, + @NonNull String url, + @NonNull String name, + @NonNull String secret, + int numberOfNodes, + @NonNull List commandLineArgs) + implements Serializable { + @Deprecated + public AgentArguments( + @NonNull String agentJnlpUrl, + @NonNull File agentJar, + @NonNull String secret, + int numberOfNodes, + @NonNull List commandLineArgs) { + this(agentJar, parseUrlAndName(agentJnlpUrl), secret, numberOfNodes, commandLineArgs); + } + + @Deprecated + private static String[] parseUrlAndName(@NonNull String agentJnlpUrl) { + // TODO separate method pending JEP-447 + var m = Pattern.compile("(.+)computer/([^/]+)/slave-agent[.]jnlp").matcher(agentJnlpUrl); + if (!m.matches()) { + throw new IllegalArgumentException(agentJnlpUrl); + } + return new String[] {m.group(1), URI.create(m.group(2)).getPath()}; + } + + @Deprecated + private AgentArguments( + @NonNull File agentJar, + @NonNull String[] urlAndName, + @NonNull String secret, + int numberOfNodes, + @NonNull List commandLineArgs) { + this(agentJar, urlAndName[0], urlAndName[1], secret, numberOfNodes, commandLineArgs); + } + + @Deprecated + public String agentJnlpUrl() { + try { + return url + "computer/" + new URI(null, name, null).toString() + "/slave-agent.jnlp"; + } catch (URISyntaxException x) { + throw new RuntimeException(x); + } + } + } + + private static AgentArguments getAgentArguments(JenkinsRule r, String name) throws IOException { + Node node = r.jenkins.getNode(name); + if (node == null) { + throw new AssertionError("no such agent: " + name); + } + SlaveComputer c = (SlaveComputer) node.toComputer(); + if (c == null) { + throw new AssertionError("agent " + node + " has no executor"); + } + JNLPLauncher launcher = (JNLPLauncher) c.getLauncher(); + List commandLineArgs = List.of(); + if (!launcher.getWorkDirSettings().isDisabled()) { + commandLineArgs = launcher.getWorkDirSettings().toCommandLineArgs(c); + } + File agentJar = Files.createTempFile(Path.of(System.getProperty("java.io.tmpdir")), "agent", ".jar") + .toFile(); + FileUtils.copyURLToFile(new Slave.JnlpJar("agent.jar").getURL(), agentJar); + return new AgentArguments( + agentJar, + r.jenkins.getRootUrl(), + name, + c.getJnlpMac(), + r.jenkins.getNodes().size(), + commandLineArgs); + } + + private static void waitForAgentOnline(JenkinsRule r, String name, Map loggers) throws Exception { + Node node = r.jenkins.getNode(name); + if (node == null) { + throw new AssertionError("no such agent: " + name); + } + if (!(node instanceof Slave)) { + throw new AssertionError("agent is not a Slave: " + name); + } + r.waitOnline((Slave) node); + if (!loggers.isEmpty()) { + VirtualChannel channel = node.getChannel(); + assert channel != null; + channel.call(new JenkinsRule.RemoteLogDumper(null, loggers, false)); + } + } + + private static void waitForAgentOffline(JenkinsRule r, String name) throws InterruptedException { + Computer c = r.jenkins.getComputer(name); + if (c != null) { + while (c.isOnline()) { + Thread.sleep(100); + } + } + } + + private static String[] createAgentRJR(JenkinsRule r, Options options) throws Throwable { + var agent = createAgentJR(r, options); + return new String[] {options.getName(), agent.getRemoteFS()}; + } + + @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "just for test code") + private static Slave createAgentJR(JenkinsRule r, Options options) + throws Descriptor.FormException, IOException, InterruptedException { + if (options.getName() == null) { + options.name = "agent" + r.jenkins.getNodes().size(); + } + JNLPLauncher launcher = new JNLPLauncher(options.getTunnel()); + DumbSlave s = new DumbSlave( + options.getName(), + Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")), options.getName() + "-work") + .toString(), + launcher); + s.setLabelString(options.getLabel()); + s.setRetentionStrategy(RetentionStrategy.NOOP); + r.jenkins.addNode(s); + // SlaveComputer#_connect runs asynchronously. Wait for it to finish for a more deterministic test. + Computer computer = s.toComputer(); + while (computer == null || computer.getOfflineCause() == null) { + Thread.sleep(100); + computer = s.toComputer(); + } + return s; + } +} diff --git a/src/main/java/org/jvnet/hudson/test/junit/jupiter/JenkinsSessionExtension.java b/src/main/java/org/jvnet/hudson/test/junit/jupiter/JenkinsSessionExtension.java new file mode 100644 index 000000000..f8055f1bc --- /dev/null +++ b/src/main/java/org/jvnet/hudson/test/junit/jupiter/JenkinsSessionExtension.java @@ -0,0 +1,135 @@ +/* + * The MIT License + * + * Copyright 2020 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.junit.jupiter; + +import java.io.File; +import java.lang.reflect.Method; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TemporaryDirectoryAllocator; + +/** + * {@link JenkinsRule} derivative which allows Jenkins to be restarted in the middle of a test. + * It also supports running test code before, between, or after Jenkins sessions, + * whereas a test method using {@link JenkinsRule} directly will only run after Jenkins has started and must complete before Jenkins terminates. + */ +public class JenkinsSessionExtension implements BeforeEachCallback, AfterEachCallback { + + private static final Logger LOGGER = Logger.getLogger(JenkinsSessionExtension.class.getName()); + + private final TemporaryDirectoryAllocator tmp = new TemporaryDirectoryAllocator(); + + private ExtensionContext extensionContext; + + private Description description; + + /** + * JENKINS_HOME needs to survive restarts, so we allocate our own. + */ + private File home; + + /** + * TCP/IP port that the server is listening on. + * Like the home directory, this will be consistent across restarts. + */ + private int port; + + /** + * Get the Jenkins home directory, which is consistent across restarts. + */ + public File getHome() { + if (home == null) { + throw new IllegalStateException("JENKINS_HOME has not been allocated yet"); + } + return home; + } + + @Override + public void beforeEach(ExtensionContext context) { + extensionContext = context; + + description = Description.createTestDescription( + extensionContext.getTestClass().map(Class::getName).orElse(null), + extensionContext.getTestMethod().map(Method::getName).orElse(null), + extensionContext.getTestMethod().map(Method::getAnnotations).orElse(null)); + + try { + home = tmp.allocate(); + } catch (Exception x) { + LOGGER.log(Level.WARNING, null, x); + } + } + + @Override + public void afterEach(ExtensionContext context) { + try { + tmp.dispose(); + } catch (Exception x) { + LOGGER.log(Level.WARNING, null, x); + } + } + + /** + * One step to run, intended to be a SAM for lambdas with {@link #then}. + */ + @FunctionalInterface + public interface Step { + void run(JenkinsRule r) throws Throwable; + } + + /** + * Run one Jenkins session and shut down. + */ + public void then(Step s) throws Throwable { + CustomJenkinsRule r = new CustomJenkinsRule(home, port); + r.apply( + new Statement() { + @Override + public void evaluate() throws Throwable { + port = r.getPort(); + s.run(r); + } + }, + description) + .evaluate(); + } + + private static final class CustomJenkinsRule extends JenkinsRule { + CustomJenkinsRule(File home, int port) { + with(() -> home); + localPort = port; + } + + int getPort() { + return localPort; + } + } +} diff --git a/src/main/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtension.java b/src/main/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtension.java new file mode 100644 index 000000000..d160ef042 --- /dev/null +++ b/src/main/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtension.java @@ -0,0 +1,2041 @@ +/* + * The MIT License + * + * Copyright 2021 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.junit.jupiter; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +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; +import hudson.security.ACLContext; +import hudson.security.csrf.CrumbExclusion; +import hudson.util.NamingThreadFactory; +import hudson.util.StreamCopyThread; +import io.jenkins.test.fips.FIPSTestBundleProvider; +import java.io.*; +import java.lang.management.ManagementFactory; +import java.lang.management.RuntimeMXBean; +import java.lang.reflect.Method; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileAttribute; +import java.security.*; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.jar.*; +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +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; +import javax.net.ssl.TrustManagerFactory; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import jenkins.model.Jenkins; +import jenkins.model.JenkinsLocationConfiguration; +import jenkins.test.https.KeyStoreManager; +import jenkins.util.Timer; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.htmlunit.WebClient; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.rules.DisableOnDebug; +import org.junit.rules.ErrorCollector; +import org.junit.rules.TemporaryFolder; +import org.junit.rules.Timeout; +import org.junit.runner.Description; +import org.jvnet.hudson.test.*; +import org.jvnet.hudson.test.recipes.LocalData; +import org.kohsuke.stapler.*; +import org.kohsuke.stapler.verb.POST; +import org.opentest4j.TestAbortedException; + +/** + * Like {@link JenkinsSessionExtension} but running Jenkins in a more realistic environment. + *

Though Jenkins is run in a separate JVM using Winstone ({@code java -jar jenkins.war}), + * you can still do “whitebox” testing: directly calling Java API methods, starting from {@link JenkinsRule} or not. + * This is because the test code gets sent to the remote JVM and loaded and run there. + * (Thus when using Maven, there are at least three JVMs involved: + * Maven itself; the Surefire booter with your top-level test code; and the Jenkins controller with test bodies.) + * Just as with {@link JenkinsRule}, all plugins found in the test classpath will be enabled, + * but with more realistic behavior: class loaders in a graph, {@code pluginFirstClassLoader} and {@code maskClasses}, etc. + *

“Compile-on-save” style development works for classes and resources in the current plugin: + * with a suitable IDE, you can edit a source file, have it be sent to {@code target/classes/}, + * and rerun a test without needing to go through a full Maven build cycle. + * This is because {@code target/test-classes/the.hpl} is used to load unpacked plugin resources. + *

Like {@link JenkinsRule}, the controller is started in “development mode”: + * the setup wizard is suppressed, the update center is not checked, etc. + *

Known limitations: + *

    + *
  • Execution is a bit slower due to the overhead of launching a new JVM; and class loading overhead cannot be shared between test cases. More memory is needed. + *
  • Remote calls must be serializable. Use methods like {@link #runRemotely(RealJenkinsExtension.StepWithReturnAndOneArg, Serializable)} and/or {@link XStreamSerializable} as needed. + *
  • {@code static} state cannot be shared between the top-level test code and test bodies (though the compiler will not catch this mistake). + *
  • When using a snapshot dep on Jenkins core, you must build {@code jenkins.war} to test core changes (there is no “compile-on-save” support for this). + *
  • {@link TestExtension} is not available (but try {@link #addSyntheticPlugin}). + *
  • {@link LoggerRule} is not available, however additional loggers can be configured via {@link #withLogger(Class, Level)}}. + *
  • {@link BuildWatcher} is not available, but you can use {@link TailLog} instead. + *
+ *

Systems not yet tested: + *

    + *
  • Possibly {@link Timeout} can be used. + *
+ */ +@SuppressFBWarnings(value = "URLCONNECTION_SSRF_FD", justification = "irrelevant") +public class RealJenkinsExtension implements BeforeEachCallback, AfterEachCallback { + + private static final Logger LOGGER = Logger.getLogger(RealJenkinsExtension.class.getName()); + + private static final String REAL_JENKINS_EXTENSION_LOGGING = "RealJenkinsExtension.logging."; + + private Description description; + + private ExtensionContext extensionContext; + + private final TemporaryDirectoryAllocator tmp = new TemporaryDirectoryAllocator(); + + /** + * JENKINS_HOME dir, consistent across restarts. + */ + private AtomicReference home; + + /** + * TCP/IP port that the server is listening on. + *

+ * Before the first start, it will be 0. Once started, it is set to the actual port Jenkins is listening to. + *

+ * Like the home directory, this will be consistent across restarts. + */ + private int port; + + private String httpListenAddress = InetAddress.getLoopbackAddress().getHostAddress(); + + private File war; + + private String javaHome; + + private boolean includeTestClasspathPlugins = true; + + private final String token = UUID.randomUUID().toString(); + + private final Set extraPlugins = new TreeSet<>(); + + private final List syntheticPlugins = new ArrayList<>(); + + private final Set skippedPlugins = new TreeSet<>(); + + private final List javaOptions = new ArrayList<>(); + + private final List jenkinsOptions = new ArrayList<>(); + + private final Map extraEnv = new TreeMap<>(); + + private int timeout = Integer.getInteger("jenkins.test.timeout", new DisableOnDebug(null).isDebugging() ? 0 : 600); + + private String host = "localhost"; + + Process proc; + + private Path portFile; + + private Map loggers = new HashMap<>(); + + private int debugPort = 0; + private boolean debugServer = true; + private boolean debugSuspend; + + private boolean prepareHomeLazily; + private boolean provisioned; + private final List bootClasspathFiles = new ArrayList<>(); + + // TODO may need to be relaxed for Gradle-based plugins + private static final Pattern SNAPSHOT_INDEX_JELLY = Pattern.compile("(file:/.+/target)/classes/index.jelly"); + + private final PrefixedOutputStream.Builder prefixedOutputStreamBuilder = PrefixedOutputStream.builder(); + private boolean https; + private KeyStoreManager keyStoreManager; + private SSLSocketFactory sslSocketFactory; + private X509Certificate rootCA; + + @NonNull + private String prefix = "/jenkins"; + + public RealJenkinsExtension() { + home = new AtomicReference<>(); + } + + /** + * Links this extension 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}, {@link #addSyntheticPlugin}, and {@link #omitPlugins}. + * Other configuration such as {@link #javaOptions(String...)} may be applied to both, but that is your choice. + */ + public RealJenkinsExtension(RealJenkinsExtension source) { + this.home = source.home; + this.includeTestClasspathPlugins = source.includeTestClasspathPlugins; + this.extraPlugins.addAll(source.extraPlugins); + this.syntheticPlugins.addAll(source.syntheticPlugins); + this.skippedPlugins.addAll(source.skippedPlugins); + } + + /** + * Add some plugins to the test classpath. + * + * @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. + *

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. + *

In most cases you do not need this method. Simply add whatever plugins you are + * interested in testing against to your POM in {@code test} scope. These, and their + * transitive dependencies, will be loaded in all {@link RealJenkinsExtension} tests. This method + * is useful if only a particular test may load the tested plugin, or if the tested plugin + * is not available in a repository for use as a test dependency. + */ + public RealJenkinsExtension addPlugins(String... plugins) { + extraPlugins.addAll(List.of(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). + *

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 RealJenkinsExtension}-based tests requesting this plugin. + * + * @param plugin the configured {@link SyntheticPlugin} + */ + public RealJenkinsExtension addSyntheticPlugin(SyntheticPlugin plugin) { + syntheticPlugins.add(plugin); + return this; + } + + /** + * Creates a test-only plugin based on sources defined in this module, but does not install it. + *

See {@link #addSyntheticPlugin} for more details. Prefer that method if you simply want the + * plugin to be installed automatically. + * + * @param plugin the configured {@link SyntheticPlugin} + * @return the JPI file for the plugin + * @see #addSyntheticPlugin + */ + @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "irrelevant, this is test code") + public File createSyntheticPlugin(SyntheticPlugin plugin) throws IOException, URISyntaxException { + File pluginJpi = new File(tmp.allocate("synthetic-plugin"), plugin.shortName + ".jpi"); + if (war == null) { + throw new IllegalStateException("createSyntheticPlugin may only be invoked from within a test method"); + } + try (JarFile jf = new JarFile(war)) { + String jenkinsVersion = jf.getManifest().getMainAttributes().getValue("Jenkins-Version"); + plugin.writeTo(pluginJpi, jenkinsVersion); + } + return pluginJpi; + } + + /** + * Omit some plugins in the test classpath. + * + * @param plugins one or more code names, like {@code token-macro} + */ + public RealJenkinsExtension omitPlugins(String... plugins) { + skippedPlugins.addAll(List.of(plugins)); + return this; + } + + /** + * Add some JVM startup options. + * + * @param options one or more options, like {@code -Dorg.jenkinsci.Something.FLAG=true} + */ + public RealJenkinsExtension javaOptions(String... options) { + javaOptions.addAll(List.of(options)); + return this; + } + + /** + * Add some Jenkins (including Winstone) startup options. + * You probably meant to use {@link #javaOptions(String...)}. + * + * @param options one or more options, like {@code --webroot=/tmp/war --pluginroot=/tmp/plugins} + */ + public RealJenkinsExtension jenkinsOptions(String... options) { + jenkinsOptions.addAll(List.of(options)); + return this; + } + + /** + * Set an extra environment variable. + * + * @param value null to cancel a previously set variable + */ + public RealJenkinsExtension extraEnv(String key, String value) { + extraEnv.put(key, value); + return this; + } + + /** + * Adjusts the test timeout. + * The timer starts when {@link #startJenkins} completes and {@link #runRemotely} is ready. + * The default is currently set to 600 (10m). + * + * @param timeout number of seconds before exiting, or zero to disable + */ + public RealJenkinsExtension withTimeout(int timeout) { + this.timeout = timeout; + return this; + } + + /** + * Sets a custom host name for the Jenkins root URL. + *

By default, this is just {@code localhost}. + * But you may wish to set it to something else that resolves to localhost, + * such as {@code some-id.localtest.me}. + * This is particularly useful when running multiple copies of Jenkins (and/or other services) in one test case, + * since browser cookies are sensitive to host but not port and so otherwise {@link HttpServletRequest#getSession} + * might accidentally be shared across otherwise distinct services. + *

Calling this method does not change the fact that Jenkins will be configured to listen only on localhost for security reasons + * (so others in the same network cannot access your system under test, especially if it lacks authentication). + *

+ * When using HTTPS, use {@link #https(String, KeyStoreManager, X509Certificate)} instead. + */ + public RealJenkinsExtension withHost(String host) { + if (https) { + throw new IllegalStateException("Don't call this method when using HTTPS"); + } + this.host = host; + return this; + } + + /** + * Sets a custom prefix for the Jenkins root URL. + *

+ * By default, the prefix defaults to {@code /jenkins}. + *

+ * If not empty, must start with '/' and not end with '/'. + */ + public RealJenkinsExtension withPrefix(@NonNull String prefix) { + if (!prefix.isEmpty()) { + if (!prefix.startsWith("/")) { + throw new IllegalArgumentException("Prefix must start with a leading slash."); + } + if (prefix.endsWith("/")) { + throw new IllegalArgumentException("Prefix must not end with a trailing slash."); + } + } + this.prefix = prefix; + return this; + } + + /** + * Sets a custom WAR file to be used by the extension instead of the one in the path or {@code war/target/jenkins.war} in case of core. + */ + public RealJenkinsExtension withWar(File war) { + this.war = war; + return this; + } + + /** + * Allows to specify a java home, defaults to JAVA_HOME if not used + */ + public RealJenkinsExtension withJavaHome(String JavaHome) { + this.javaHome = JavaHome; + return this; + } + + public RealJenkinsExtension withLogger(Class clazz, Level level) { + return withLogger(clazz.getName(), level); + } + + public RealJenkinsExtension withPackageLogger(Class clazz, Level level) { + return withLogger(clazz.getPackageName(), level); + } + + public RealJenkinsExtension withLogger(String logger, Level level) { + this.loggers.put(logger, level); + return this; + } + + /** + * Sets a name for this instance, which will be prefixed to log messages to simplify debugging. + */ + public RealJenkinsExtension withName(String name) { + prefixedOutputStreamBuilder.withName(name); + return this; + } + + public String getName() { + return prefixedOutputStreamBuilder.getName(); + } + + /** + * Applies ANSI coloration to log lines produced by this instance, complementing {@link #withName}. + * Ignored when on CI. + */ + public RealJenkinsExtension withColor(PrefixedOutputStream.AnsiColor color) { + prefixedOutputStreamBuilder.withColor(color); + return this; + } + + /** + * Provides a custom fixed port instead of a random one. + * + * @param port a custom port to use instead of a random one. + */ + public RealJenkinsExtension withPort(int port) { + this.port = port; + return this; + } + + /** + * Provides a custom interface to listen to. + *

Important: for security reasons this should be overridden only in special scenarios, + * such as testing inside a Docker container. + * Otherwise a developer running tests could inadvertently expose a Jenkins service without password protection, + * allowing remote code execution. + * + * @param httpListenAddress network interface such as

0.0.0.0
. Defaults to
127.0.0.1
. + */ + public RealJenkinsExtension withHttpListenAddress(String httpListenAddress) { + this.httpListenAddress = httpListenAddress; + return this; + } + + /** + * Allows usage of a static debug port instead of a random one. + *

+ * This allows to use predefined debug configurations in the IDE. + *

+ * Typical usage is in a base test class where multiple named controller instances are defined with fixed ports + * + *

+     * public RealJenkinsExtension cc1 = new RealJenkinsExtension().withName("cc1").withDebugPort(4001).withDebugServer(false);
+     *
+     * public RealJenkinsExtension cc2 = new RealJenkinsExtension().withName("cc2").withDebugPort(4002).withDebugServer(false);
+     * 
+ *

+ * Then have debug configurations in the IDE set for ports + *

    + *
  • 5005 (test VM) - debugger mode "attach to remote vm"
  • + *
  • 4001 (cc1) - debugger mode "listen to remote vm"
  • + *
  • 4002 (cc2) - debugger mode "listen to remote vm"
  • + *
+ *

+ * This allows for debugger to reconnect in scenarios where restarts of controllers are involved. + * + * @param debugPort the TCP port to use for debugging this Jenkins instance. Between 0 (random) and 65536 (excluded). + */ + public RealJenkinsExtension withDebugPort(int debugPort) { + if (debugPort < 0) { + throw new IllegalArgumentException("debugPort must be positive"); + } + if (!(debugPort < 65536)) { + throw new IllegalArgumentException("debugPort must be a valid TCP port (< 65536)"); + } + this.debugPort = debugPort; + return this; + } + + /** + * Allows to use debug in server mode or client mode. Client mode is friendlier to controller restarts. + * + * @param debugServer true to use server=y, false to use server=n + * @see #withDebugPort(int) + */ + public RealJenkinsExtension withDebugServer(boolean debugServer) { + this.debugServer = debugServer; + return this; + } + + /** + * Whether to suspend the controller VM on startup until debugger is connected. Defaults to false. + * + * @param debugSuspend true to suspend the controller VM on startup until debugger is connected. + */ + public RealJenkinsExtension withDebugSuspend(boolean debugSuspend) { + this.debugSuspend = debugSuspend; + return this; + } + + /** + * The intended use case for this is to use the plugins bundled into the war {@link RealJenkinsExtension#withWar(File)} + * instead of the plugins in the pom. A typical scenario for this feature is a test which does not live inside a + * plugin's src/test/java + * + * @param includeTestClasspathPlugins false if plugins from pom should not be used (default true) + */ + public RealJenkinsExtension includeTestClasspathPlugins(boolean includeTestClasspathPlugins) { + this.includeTestClasspathPlugins = includeTestClasspathPlugins; + return this; + } + + /** + * Allows {@code JENKINS_HOME} initialization to be delayed until {@link #startJenkins} is called for the first time. + *

+ * This allows methods such as {@link #addPlugins} to be called dynamically inside of test methods, which enables + * related tests that need to configure {@link RealJenkinsExtension} in different ways to be defined in the same class + * using only a single instance of {@link RealJenkinsExtension}. + */ + public RealJenkinsExtension prepareHomeLazily(boolean prepareHomeLazily) { + this.prepareHomeLazily = prepareHomeLazily; + return this; + } + + /** + * Use {@link #withFIPSEnabled(FIPSTestBundleProvider)} with default value of {@link FIPSTestBundleProvider#get()} + */ + public RealJenkinsExtension withFIPSEnabled() { + return withFIPSEnabled(FIPSTestBundleProvider.get()); + } + + /** + * @param fipsTestBundleProvider the {@link FIPSTestBundleProvider} to use for testing + */ + public RealJenkinsExtension withFIPSEnabled(FIPSTestBundleProvider fipsTestBundleProvider) { + Objects.requireNonNull(fipsTestBundleProvider, "fipsTestBundleProvider must not be null"); + try { + return withBootClasspath( + fipsTestBundleProvider.getBootClasspathFiles().toArray(new File[0])) + .javaOptions(fipsTestBundleProvider.getJavaOptions().toArray(new String[0])); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * @param files add some {@link File} to bootclasspath + */ + public RealJenkinsExtension withBootClasspath(File... files) { + this.bootClasspathFiles.addAll(List.of(files)); + return this; + } + + public static List getJacocoAgentOptions() { + RuntimeMXBean runtimeMxBean = ManagementFactory.getRuntimeMXBean(); + List arguments = runtimeMxBean.getInputArguments(); + return arguments.stream() + .filter(argument -> argument.startsWith("-javaagent:") && argument.contains("jacoco")) + .toList(); + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + extensionContext = context; + + description = Description.createTestDescription( + extensionContext.getTestClass().map(Class::getName).orElse(null), + extensionContext.getTestMethod().map(Method::getName).orElse(null)); + + System.err.println("=== Starting " + description); + + jenkinsOptions( + "--webroot=" + createTempDirectory("webroot"), "--pluginroot=" + createTempDirectory("pluginroot")); + if (war == null) { + war = findJenkinsWar(); + } + if (home.get() == null) { + home.set(tmp.allocate()); + if (!prepareHomeLazily) { + provision(); + } + } + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + System.err.println("=== Stopping " + description); + + stopJenkins(); + + try { + tmp.dispose(); + } catch (Exception x) { + LOGGER.log(Level.WARNING, null, x); + } + } + + /** + * Initializes {@code JENKINS_HOME}, but does not start Jenkins. + */ + @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "irrelevant") + private void provision() throws Exception { + provisioned = true; + if (home.get() == null) { + home.set(tmp.allocate()); + } + LocalData localData = extensionContext.getTestMethod().orElseThrow().getAnnotation(LocalData.class); + if (localData != null) { + new HudsonHomeLoader.Local(extensionContext.getTestMethod().orElseThrow(), localData.value()) + .copy(getHome()); + } + + 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)) { + targetJenkinsVersion = jf.getManifest().getMainAttributes().getValue("Jenkins-Version"); + PluginUtils.createRealJenkinsExtensionPlugin(plugins, targetJenkinsVersion); + } + + if (includeTestClasspathPlugins) { + // Adapted from UnitTestSupportingPluginManager & JenkinsRule.recipeLoadCurrentPlugin: + Set snapshotPlugins = new TreeSet<>(); + Enumeration indexJellies = + RealJenkinsExtension.class.getClassLoader().getResources("index.jelly"); + while (indexJellies.hasMoreElements()) { + String indexJelly = indexJellies.nextElement().toString(); + Matcher m = SNAPSHOT_INDEX_JELLY.matcher(indexJelly); + if (m.matches()) { + Path snapshotManifest; + snapshotManifest = Paths.get(URI.create(m.group(1) + "/test-classes/the.jpl")); + if (!Files.exists(snapshotManifest)) { + snapshotManifest = Paths.get(URI.create(m.group(1) + "/test-classes/the.hpl")); + } + if (Files.exists(snapshotManifest)) { + String shortName; + try (InputStream is = Files.newInputStream(snapshotManifest)) { + shortName = new Manifest(is).getMainAttributes().getValue("Short-Name"); + } + if (shortName == null) { + throw new IOException("malformed " + snapshotManifest); + } + if (skippedPlugins.contains(shortName)) { + continue; + } + // Not totally realistic, but test phase is run before package phase. TODO can we add an option + // to run in integration-test phase? + Files.copy(snapshotManifest, plugins.toPath().resolve(shortName + ".jpl")); + snapshotPlugins.add(shortName); + } else { + System.err.println("Warning: found " + indexJelly + + " but did not find corresponding ../test-classes/the.[hj]pl"); + } + } else { + // Do not warn about the common case of jar:file:/**/.m2/repository/**/*.jar!/index.jelly + } + } + URL index = RealJenkinsExtension.class.getResource("/test-dependencies/index"); + if (index != null) { + try (BufferedReader r = + new BufferedReader(new InputStreamReader(index.openStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + if (snapshotPlugins.contains(line) || skippedPlugins.contains(line)) { + continue; + } + final URL url = new URL(index, line + ".jpi"); + File f; + try { + f = new File(url.toURI()); + } catch (IllegalArgumentException x) { + if (x.getMessage().equals("URI is not hierarchical")) { + throw new IOException( + "You are probably trying to load plugins from within a jarfile (not possible). If" + + " you are running this in your IDE and see this message, it is likely" + + " that you have a clean target directory. Try running 'mvn test-compile' " + + "from the command line (once only), which will copy the required plugins " + + "into target/test-classes/test-dependencies - then retry your test", + x); + } else { + throw new IOException(index + " contains bogus line " + line, x); + } + } + if (f.exists()) { + FileUtils.copyURLToFile(url, new File(plugins, line + ".jpi")); + } else { + FileUtils.copyURLToFile(new URL(index, line + ".hpi"), new File(plugins, line + ".jpi")); + } + } + } + } + } + for (String extraPlugin : extraPlugins) { + URL url = RealJenkinsExtension.class.getClassLoader().getResource(extraPlugin); + String name; + try (InputStream is = url.openStream(); + JarInputStream jis = new JarInputStream(is)) { + Manifest man = jis.getManifest(); + if (man == null) { + throw new IOException("No manifest found in " + extraPlugin); + } + name = man.getMainAttributes().getValue("Short-Name"); + if (name == null) { + throw new IOException("No Short-Name found in " + extraPlugin); + } + } + FileUtils.copyURLToFile(url, new File(plugins, name + ".jpi")); + } + for (SyntheticPlugin syntheticPlugin : syntheticPlugins) { + syntheticPlugin.writeTo(new File(plugins, syntheticPlugin.shortName + ".jpi"), targetJenkinsVersion); + } + System.err.println("Will load plugins: " + + Stream.of(plugins.list()) + .filter(n -> n.matches(".+[.][hj]p[il]")) + .sorted() + .collect(Collectors.joining(" "))); + } + + /** + * Deletes {@code JENKINS_HOME}. + *

+ * This method does not need to be invoked when using {@code @Rule} or {@code @ClassRule} to run {@code RealJenkinsRule}. + */ + public void deprovision() throws Exception { + tmp.dispose(); + home.set(null); + provisioned = false; + } + + /** + * Creates a temporary directory. + * Unlike {@link Files#createTempDirectory(String, FileAttribute...)} + * this will be cleaned up after the test exits (like {@link TemporaryFolder}), + * and will honor {@code java.io.tmpdir} set by Surefire + * after {@code StaticProperty.JAVA_IO_TMPDIR} has been initialized. + */ + public Path createTempDirectory(String prefix) throws IOException { + return tmp.allocate(prefix).toPath(); + } + + /** + * Returns true if the Jenkins process is alive. + */ + public boolean isAlive() { + return proc != null && proc.isAlive(); + } + + public String[] getTruststoreJavaOptions() { + return keyStoreManager != null ? keyStoreManager.getTruststoreJavaOptions() : new String[0]; + } + + /** + * One step to run. + *

Since this thunk will be sent to a different JVM, it must be serializable. + * The test class will certainly not be serializable, so you cannot use an anonymous inner class. + * One idiom is a static method reference: + *

+     * @Test public void stuff() throws Throwable {
+     *     rr.then(YourTest::_stuff);
+     * }
+     * private static void _stuff(JenkinsRule r) throws Throwable {
+     *     // as needed
+     * }
+     * 
+ * If you need to pass and/or return values, you can still use a static method reference: + * try {@link #runRemotely(Step2)} or {@link #runRemotely(StepWithReturnAndOneArg, Serializable)} etc. + * (using {@link XStreamSerializable} as needed). + *

+ * Alternately, you could use a lambda: + *

+     * @Test public void stuff() throws Throwable {
+     *     rr.then(r -> {
+     *         // as needed
+     *     });
+     * }
+     * 
+ * In this case you must take care not to capture non-serializable objects from scope; + * in particular, the body must not use (named or anonymous) inner classes. + */ + @FunctionalInterface + public interface Step extends Serializable { + void run(JenkinsRule r) throws Throwable; + } + + @FunctionalInterface + public interface Step2 extends Serializable { + T run(JenkinsRule r) throws Throwable; + } + + /** + * Run one Jenkins session, send one or more test thunks, and shut down. + */ + public void then(Step... steps) throws Throwable { + then(new StepsToStep2(steps)); + } + + /** + * Run one Jenkins session, send a test thunk, and shut down. + */ + public T then(Step2 s) throws Throwable { + startJenkins(); + try { + return runRemotely(s); + } finally { + stopJenkins(); + } + } + + /** + * Similar to {@link JenkinsRule#getURL}. Requires Jenkins to be started before using {@link #startJenkins()}. + *

+ * Always ends with a '/'. + */ + public URL getUrl() throws MalformedURLException { + if (port == 0) { + throw new IllegalStateException("This method must be called after calling #startJenkins."); + } + return new URL(https ? "https" : "http", host, port, prefix + "/"); + } + + /** + * Sets up HTTPS for the current instance, and disables plain HTTP. + * This generates a self-signed certificate for localhost. The corresponding root CA that needs to be trusted by HTTP client can be obtained using {@link #getRootCA()}. + * + * @return the current instance + * @see #createWebClient() + */ + public RealJenkinsExtension https() { + try { + var keyStorePath = tmp.allocate().toPath().resolve("test-keystore.p12"); + IOUtils.copy(getClass().getResource("/https/test-keystore.p12"), keyStorePath.toFile()); + var keyStoreManager = new KeyStoreManager(keyStorePath, "changeit"); + try (var is = getClass().getResourceAsStream("/https/test-cert.pem")) { + var cert = (X509Certificate) + CertificateFactory.getInstance("X.509").generateCertificate(is); + https("localhost", keyStoreManager, cert); + } + } catch (CertificateException | KeyStoreException | NoSuchAlgorithmException | IOException e) { + throw new RuntimeException(e); + } + return this; + } + + /** + * Sets up HTTPS for the current instance, and disables plain HTTP. + *

+ * You don't need to call {@link #withHost(String)} when calling this method. + * + * @param host the host name to use in the certificate + * @param keyStoreManager a key store manager containing the key and certificate to use for HTTPS. It needs to be valid for the given host + * @param rootCA the certificate that needs to be trusted by callers. + * @return the current instance + * @see #createWebClient() + * @see #withHost(String) + */ + public RealJenkinsExtension https( + @NonNull String host, @NonNull KeyStoreManager keyStoreManager, @NonNull X509Certificate rootCA) { + this.host = host; + this.https = true; + this.keyStoreManager = keyStoreManager; + try { + this.sslSocketFactory = keyStoreManager.buildClientSSLContext().getSocketFactory(); + } catch (NoSuchAlgorithmException + | KeyManagementException + | CertificateException + | KeyStoreException + | IOException e) { + throw new RuntimeException(e); + } + this.rootCA = rootCA; + return this; + } + + /** + * @return the current autogenerated root CA or null if {@link #https()} has not been called. + */ + @Nullable + public X509Certificate getRootCA() { + return rootCA; + } + + /** + * Returns the autogenerated self-signed root CA in PEM format, or null if {@link #https()} has not been called. + * Typically used to configure {@link InboundAgentRule.Options.Builder#cert}. + * + * @return the root CA in PEM format, or null if unavailable + */ + @Nullable + public String getRootCAPem() { + if (rootCA == null) { + return null; + } + try (var is = getClass().getResourceAsStream("/https/test-cert.pem")) { + assert is != null; + return IOUtils.toString(is, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Builds a {@link SSLContext} trusting the current instance. + */ + @NonNull + public SSLContext buildSSLContext() throws NoSuchAlgorithmException { + if (rootCA != null) { + try { + var myTrustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + myTrustStore.load(null, null); + myTrustStore.setCertificateEntry( + getName() != null ? getName() : UUID.randomUUID().toString(), rootCA); + var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(myTrustStore); + var context = SSLContext.getInstance("TLS"); + context.init(null, trustManagerFactory.getTrustManagers(), null); + return context; + } catch (CertificateException | KeyManagementException | IOException | KeyStoreException e) { + throw new RuntimeException(e); + } + } else { + return SSLContext.getDefault(); + } + } + + private URL endpoint(String method) throws MalformedURLException { + return new URL(getUrl(), "RealJenkinsExtension/" + method + "?token=" + token); + } + + /** + * Obtains the Jenkins home directory. + * Normally it will suffice to use {@link LocalData} to populate files. + */ + public File getHome() { + return home.get(); + } + + /** + * Switch the Jenkins home directory. + * Will affect subsequent startups of this extension, + * but not other copies linked via {@link RealJenkinsExtension#RealJenkinsExtension(RealJenkinsExtension)}. + * Normally unnecessary but could be used to simulate running on the wrong home. + */ + public void setHome(File newHome) { + home = new AtomicReference<>(newHome); + } + + private static File findJenkinsWar() throws Exception { + // Adapted from WarExploder.explode + + // Are we in Jenkins core? If so, pick up "war/target/jenkins.war". + File d = new File(".").getAbsoluteFile(); + for (; d != null; d = d.getParentFile()) { + if (new File(d, ".jenkins").exists()) { + File war = new File(d, "war/target/jenkins.war"); + if (war.exists()) { + LOGGER.log(Level.INFO, "Using jenkins.war from {0}", war); + return war; + } + } + } + + return WarExploder.findJenkinsWar(); + } + + /** + * Create a client configured to trust any self-signed certificate used by this instance. + */ + public WebClient createWebClient() { + var wc = new WebClient(); + if (keyStoreManager != null) { + keyStoreManager.configureWebClient(wc); + } + return wc; + } + + @SuppressFBWarnings( + value = {"PATH_TRAVERSAL_IN", "URLCONNECTION_SSRF_FD", "COMMAND_INJECTION"}, + justification = "irrelevant") + public void startJenkins() throws Exception { + if (proc != null) { + throw new IllegalStateException("Jenkins is (supposedly) already running"); + } + if (prepareHomeLazily && !provisioned) { + provision(); + } + var metadata = createTempDirectory("RealJenkinsExtension"); + var cpFile = metadata.resolve("cp.txt"); + String cp = System.getProperty("java.class.path"); + Files.writeString( + cpFile, + Stream.of(cp.split(File.pathSeparator)).collect(Collectors.joining(System.lineSeparator())), + StandardCharsets.UTF_8); + List argv = new ArrayList<>(List.of( + new File(javaHome != null ? javaHome : System.getProperty("java.home"), "bin/java").getAbsolutePath(), + "-ea", + "-Dhudson.Main.development=true", + "-DRealJenkinsExtension.classpath=" + cpFile, + "-DRealJenkinsExtension.location=" + + RealJenkinsExtension.class + .getProtectionDomain() + .getCodeSource() + .getLocation(), + "-DRealJenkinsExtension.description=" + description, + "-DRealJenkinsExtension.token=" + token)); + argv.addAll(getJacocoAgentOptions()); + for (Map.Entry e : loggers.entrySet()) { + argv.add("-D" + REAL_JENKINS_EXTENSION_LOGGING + e.getKey() + "=" + + e.getValue().getName()); + } + portFile = metadata.resolve("jenkins-port.txt"); + argv.add("-Dwinstone.portFileName=" + portFile); + var tmp = System.getProperty("java.io.tmpdir"); + if (tmp != null) { + argv.add("-Djava.io.tmpdir=" + tmp); + } + boolean debugging = new DisableOnDebug(null).isDebugging(); + if (debugging) { + argv.add("-agentlib:jdwp=transport=dt_socket" + + ",server=" + (debugServer ? "y" : "n") + + ",suspend=" + (debugSuspend ? "y" : "n") + + (debugPort > 0 ? ",address=" + httpListenAddress + ":" + debugPort : "")); + } + if (!bootClasspathFiles.isEmpty()) { + String fileList = bootClasspathFiles.stream() + .map(File::getAbsolutePath) + .collect(Collectors.joining(File.pathSeparator)); + argv.add("-Xbootclasspath/a:" + fileList); + } + argv.addAll(javaOptions); + + argv.addAll(List.of( + "-jar", war.getAbsolutePath(), "--enable-future-java", "--httpListenAddress=" + httpListenAddress)); + if (!prefix.isEmpty()) { + argv.add("--prefix=" + prefix); + } + argv.addAll(getPortOptions()); + if (https) { + argv.add("--httpsKeyStore=" + keyStoreManager.getPath().toAbsolutePath()); + if (keyStoreManager.getPassword() != null) { + argv.add("--httpsKeyStorePassword=" + keyStoreManager.getPassword()); + } + } + argv.addAll(jenkinsOptions); + Map env = new TreeMap<>(); + env.put("JENKINS_HOME", getHome().getAbsolutePath()); + String forkNumber = System.getProperty("surefire.forkNumber"); + if (forkNumber != null) { + // https://maven.apache.org/surefire/maven-surefire-plugin/examples/fork-options-and-parallel-execution.html#forked-test-execution + // Otherwise accessible only to the Surefire JVM, not to the Jenkins controller JVM. + env.put("SUREFIRE_FORK_NUMBER", forkNumber); + } + for (Map.Entry entry : extraEnv.entrySet()) { + if (entry.getValue() != null) { + env.put(entry.getKey(), entry.getValue()); + } + } + // TODO escape spaces like Launcher.printCommandLine, or LabelAtom.escape (beware that + // QuotedStringTokenizer.quote(String) Javadoc is untrue): + System.err.println(env.entrySet().stream().map(Map.Entry::toString).collect(Collectors.joining(" ")) + " " + + String.join(" ", argv)); + ProcessBuilder pb = new ProcessBuilder(argv); + pb.environment().putAll(env); + // TODO options to set Winstone options, etc. + // TODO pluggable launcher interface to support a Dockerized Jenkins JVM + pb.redirectErrorStream(true); + proc = pb.start(); + new StreamCopyThread( + description.toString(), proc.getInputStream(), prefixedOutputStreamBuilder.build(System.err)) + .start(); + int tries = 0; + while (true) { + if (!proc.isAlive()) { + int exitValue = proc.exitValue(); + proc = null; + throw new IOException("Jenkins process terminated prematurely with exit code " + exitValue); + } + if (port == 0 && portFile != null && Files.isRegularFile(portFile)) { + port = readPort(portFile); + } + if (port != 0) { + try { + URL status = endpoint("status"); + HttpURLConnection conn = decorateConnection(status.openConnection()); + + String checkResult = checkResult(conn); + if (checkResult == null) { + System.err.println((getName() != null ? getName() : "Jenkins") + " is running at " + getUrl()); + break; + } else { + throw new IOException("Response code " + conn.getResponseCode() + " for " + status + ": " + + checkResult + " " + conn.getHeaderFields()); + } + + } catch (JenkinsStartupException jse) { + // Jenkins has completed startup but failed + // do not make any further attempts and kill the process + proc.destroyForcibly(); + proc = null; + throw jse; + } catch (Exception x) { + tries++; + if (!debugging && tries == /* 3m */ 1800) { + throw new AssertionError("Jenkins did not start after 3m"); + } else if (tries % /* 1m */ 600 == 0) { + x.printStackTrace(); + } + } + } + Thread.sleep(100); + } + addTimeout(); + } + + private Collection getPortOptions() { + // Initially port=0. On subsequent runs, this is set to the port allocated randomly on the first run. + if (https) { + return List.of("--httpPort=-1", "--httpsPort=" + port); + } else { + return List.of("--httpPort=" + port); + } + } + + @CheckForNull + public static String checkResult(HttpURLConnection conn) throws IOException { + int code = conn.getResponseCode(); + if (code == 200) { + conn.getInputStream().close(); + return null; + } else { + String err = "?"; + try (InputStream is = conn.getErrorStream()) { + if (is != null) { + err = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } catch (Exception x) { + x.printStackTrace(); + } + if (code == 500) { + throw new JenkinsStartupException(err); + } + return err; + } + } + + private void addTimeout() { + if (timeout > 0) { + Timer.get() + .schedule( + () -> { + if (proc != null) { + LOGGER.warning("Test timeout expired, stopping steps…"); + try { + decorateConnection(endpoint("timeout").openConnection()) + .getInputStream() + .close(); + } catch (IOException x) { + x.printStackTrace(); + } + LOGGER.warning("…and giving steps a chance to fail…"); + try { + Thread.sleep(15_000); + } catch (InterruptedException x) { + x.printStackTrace(); + } + LOGGER.warning("…and killing Jenkins process."); + proc.destroyForcibly(); + proc = null; + } + }, + timeout, + TimeUnit.SECONDS); + } + } + + private static int readPort(Path portFile) throws IOException { + String s = Files.readString(portFile, StandardCharsets.UTF_8); + + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + throw new AssertionError("Unable to parse port from " + s + ". Jenkins did not start."); + } + } + + /** + * Stops Jenkins and releases any system resources associated + * with it. If Jenkins is already stopped then invoking this + * method has no effect. + */ + public void stopJenkins() throws Exception { + if (proc != null) { + Process _proc = proc; + proc = null; + if (_proc.isAlive()) { + try { + decorateConnection(endpoint("exit").openConnection()) + .getInputStream() + .close(); + } catch (IOException e) { + System.err.println("Unable to connect to the Jenkins process to stop it: " + e); + } + } else { + System.err.println("Jenkins process was already terminated."); + } + if (!_proc.waitFor(60, TimeUnit.SECONDS)) { + System.err.println("Jenkins failed to stop within 60 seconds, attempting to kill the Jenkins process"); + _proc.destroyForcibly(); + throw new AssertionError("Jenkins failed to terminate within 60 seconds"); + } + int exitValue = _proc.exitValue(); + if (exitValue != 0) { + throw new AssertionError("nonzero exit code: " + exitValue); + } + } + } + + /** + * Stops Jenkins abruptly, without giving it a chance to shut down cleanly. + * If Jenkins is already stopped then invoking this method has no effect. + */ + public void stopJenkinsForcibly() { + if (proc != null) { + var _proc = proc; + proc = null; + System.err.println("Killing the Jenkins process as requested"); + _proc.destroyForcibly(); + } + } + + /** + * Runs one or more steps on the remote system. + * (Compared to multiple calls, passing a series of steps is slightly more efficient + * as only one network call is made.) + */ + public void runRemotely(Step... steps) throws Throwable { + runRemotely(new StepsToStep2(steps)); + } + + /** + * Run a step on the remote system. + * Alias for {@link #runRemotely(RealJenkinsExtension.Step...)} (with one step) + * that is easier to resolve for lambdas. + */ + public void run(Step step) throws Throwable { + runRemotely(step); + } + + /** + * Run a step on the remote system, but do not immediately fail, just record any error. + * Same as {@link ErrorCollector#checkSucceeds} but more concise to call. + */ + public void run(ErrorCollector errors, Step step) { + errors.checkSucceeds(() -> { + try { + run(step); + return null; + } catch (Exception x) { + throw x; + } catch (Throwable x) { + throw new Exception(x); + } + }); + } + + @SuppressWarnings("unchecked") + @SuppressFBWarnings(value = "URLCONNECTION_SSRF_FD", justification = "irrelevant") + public T runRemotely(Step2 s) throws Throwable { + HttpURLConnection conn = decorateConnection(endpoint("step").openConnection()); + conn.setRequestProperty("Content-Type", "application/octet-stream"); + conn.setDoOutput(true); + + Init2.writeSer(conn.getOutputStream(), new InputPayload(token, s, getUrl())); + try { + OutputPayload result = (OutputPayload) Init2.readSer(conn.getInputStream(), null); + if (result.assumptionFailure != null) { + throw new TestAbortedException(result.assumptionFailure, result.error); + } else if (result.error != null) { + throw new StepException(result.error, getName()); + } + return (T) result.result; + } catch (IOException e) { + try (InputStream is = conn.getErrorStream()) { + if (is != null) { + String errorMessage = new String(is.readAllBytes(), StandardCharsets.UTF_8); + e.addSuppressed(new IOException("Response body: " + errorMessage)); + } + } catch (IOException e2) { + e.addSuppressed(e2); + } + throw e; + } + } + + /** + * Run a step with a return value on the remote system. + * Alias for {@link #runRemotely(RealJenkinsExtension.Step2)} + * that is easier to resolve for lambdas. + */ + public T call(Step2 s) throws Throwable { + return runRemotely(s); + } + + private HttpURLConnection decorateConnection(@NonNull URLConnection urlConnection) { + if (sslSocketFactory != null) { + ((HttpsURLConnection) urlConnection).setSSLSocketFactory(sslSocketFactory); + } + return (HttpURLConnection) urlConnection; + } + + @FunctionalInterface + public interface StepWithOneArg extends Serializable { + void run(JenkinsRule r, A1 arg1) throws Throwable; + } + + public void runRemotely(StepWithOneArg s, A1 arg1) throws Throwable { + runRemotely(new StepWithOneArgWrapper<>(s, arg1)); + } + + private static final class StepWithOneArgWrapper implements Step { + private final StepWithOneArg delegate; + private final A1 arg1; + + StepWithOneArgWrapper(StepWithOneArg delegate, A1 arg1) { + this.delegate = delegate; + this.arg1 = arg1; + } + + @Override + public void run(JenkinsRule r) throws Throwable { + delegate.run(r, arg1); + } + } + + @FunctionalInterface + public interface StepWithTwoArgs extends Serializable { + void run(JenkinsRule r, A1 arg1, A2 arg2) throws Throwable; + } + + public void runRemotely( + StepWithTwoArgs s, A1 arg1, A2 arg2) throws Throwable { + runRemotely(new StepWithTwoArgsWrapper<>(s, arg1, arg2)); + } + + private static final class StepWithTwoArgsWrapper + implements Step { + private final StepWithTwoArgs delegate; + private final A1 arg1; + private final A2 arg2; + + StepWithTwoArgsWrapper(StepWithTwoArgs delegate, A1 arg1, A2 arg2) { + this.delegate = delegate; + this.arg1 = arg1; + this.arg2 = arg2; + } + + @Override + public void run(JenkinsRule r) throws Throwable { + delegate.run(r, arg1, arg2); + } + } + + @FunctionalInterface + public interface StepWithThreeArgs + extends Serializable { + void run(JenkinsRule r, A1 arg1, A2 arg2, A3 arg3) throws Throwable; + } + + public void runRemotely( + StepWithThreeArgs s, A1 arg1, A2 arg2, A3 arg3) throws Throwable { + runRemotely(new StepWithThreeArgsWrapper<>(s, arg1, arg2, arg3)); + } + + private static final class StepWithThreeArgsWrapper< + A1 extends Serializable, A2 extends Serializable, A3 extends Serializable> + implements Step { + private final StepWithThreeArgs delegate; + private final A1 arg1; + private final A2 arg2; + private final A3 arg3; + + StepWithThreeArgsWrapper(StepWithThreeArgs delegate, A1 arg1, A2 arg2, A3 arg3) { + this.delegate = delegate; + this.arg1 = arg1; + this.arg2 = arg2; + this.arg3 = arg3; + } + + @Override + public void run(JenkinsRule r) throws Throwable { + delegate.run(r, arg1, arg2, arg3); + } + } + + @FunctionalInterface + public interface StepWithFourArgs< + A1 extends Serializable, A2 extends Serializable, A3 extends Serializable, A4 extends Serializable> + extends Serializable { + void run(JenkinsRule r, A1 arg1, A2 arg2, A3 arg3, A4 arg4) throws Throwable; + } + + public + void runRemotely(StepWithFourArgs s, A1 arg1, A2 arg2, A3 arg3, A4 arg4) throws Throwable { + runRemotely(new StepWithFourArgsWrapper<>(s, arg1, arg2, arg3, arg4)); + } + + private static final class StepWithFourArgsWrapper< + A1 extends Serializable, A2 extends Serializable, A3 extends Serializable, A4 extends Serializable> + implements Step { + private final StepWithFourArgs delegate; + private final A1 arg1; + private final A2 arg2; + private final A3 arg3; + private final A4 arg4; + + StepWithFourArgsWrapper(StepWithFourArgs delegate, A1 arg1, A2 arg2, A3 arg3, A4 arg4) { + this.delegate = delegate; + this.arg1 = arg1; + this.arg2 = arg2; + this.arg3 = arg3; + this.arg4 = arg4; + } + + @Override + public void run(JenkinsRule r) throws Throwable { + delegate.run(r, arg1, arg2, arg3, arg4); + } + } + + @FunctionalInterface + public interface StepWithReturnAndOneArg extends Serializable { + R run(JenkinsRule r, A1 arg1) throws Throwable; + } + + public R runRemotely(StepWithReturnAndOneArg s, A1 arg1) + throws Throwable { + return runRemotely(new StepWithReturnAndOneArgWrapper<>(s, arg1)); + } + + private static final class StepWithReturnAndOneArgWrapper + implements Step2 { + private final StepWithReturnAndOneArg delegate; + private final A1 arg1; + + StepWithReturnAndOneArgWrapper(StepWithReturnAndOneArg delegate, A1 arg1) { + this.delegate = delegate; + this.arg1 = arg1; + } + + @Override + public R run(JenkinsRule r) throws Throwable { + return delegate.run(r, arg1); + } + } + + @FunctionalInterface + public interface StepWithReturnAndTwoArgs + extends Serializable { + R run(JenkinsRule r, A1 arg1, A2 arg2) throws Throwable; + } + + public R runRemotely( + StepWithReturnAndTwoArgs s, A1 arg1, A2 arg2) throws Throwable { + return runRemotely(new StepWithReturnAndTwoArgsWrapper<>(s, arg1, arg2)); + } + + private static final class StepWithReturnAndTwoArgsWrapper< + R extends Serializable, A1 extends Serializable, A2 extends Serializable> + implements Step2 { + private final StepWithReturnAndTwoArgs delegate; + private final A1 arg1; + private final A2 arg2; + + StepWithReturnAndTwoArgsWrapper(StepWithReturnAndTwoArgs delegate, A1 arg1, A2 arg2) { + this.delegate = delegate; + this.arg1 = arg1; + this.arg2 = arg2; + } + + @Override + public R run(JenkinsRule r) throws Throwable { + return delegate.run(r, arg1, arg2); + } + } + + @FunctionalInterface + public interface StepWithReturnAndThreeArgs< + R extends Serializable, A1 extends Serializable, A2 extends Serializable, A3 extends Serializable> + extends Serializable { + R run(JenkinsRule r, A1 arg1, A2 arg2, A3 arg3) throws Throwable; + } + + public + R runRemotely(StepWithReturnAndThreeArgs s, A1 arg1, A2 arg2, A3 arg3) throws Throwable { + return runRemotely(new StepWithReturnAndThreeArgsWrapper<>(s, arg1, arg2, arg3)); + } + + private static final class StepWithReturnAndThreeArgsWrapper< + R extends Serializable, A1 extends Serializable, A2 extends Serializable, A3 extends Serializable> + implements Step2 { + private final StepWithReturnAndThreeArgs delegate; + private final A1 arg1; + private final A2 arg2; + private final A3 arg3; + + StepWithReturnAndThreeArgsWrapper( + StepWithReturnAndThreeArgs delegate, A1 arg1, A2 arg2, A3 arg3) { + this.delegate = delegate; + this.arg1 = arg1; + this.arg2 = arg2; + this.arg3 = arg3; + } + + @Override + public R run(JenkinsRule r) throws Throwable { + return delegate.run(r, arg1, arg2, arg3); + } + } + + @FunctionalInterface + public interface StepWithReturnAndFourArgs< + R extends Serializable, + A1 extends Serializable, + A2 extends Serializable, + A3 extends Serializable, + A4 extends Serializable> + extends Serializable { + R run(JenkinsRule r, A1 arg1, A2 arg2, A3 arg3, A4 arg4) throws Throwable; + } + + public < + R extends Serializable, + A1 extends Serializable, + A2 extends Serializable, + A3 extends Serializable, + A4 extends Serializable> + R runRemotely(StepWithReturnAndFourArgs s, A1 arg1, A2 arg2, A3 arg3, A4 arg4) + throws Throwable { + return runRemotely(new StepWithReturnAndFourArgsWrapper<>(s, arg1, arg2, arg3, arg4)); + } + + private static final class StepWithReturnAndFourArgsWrapper< + R extends Serializable, + A1 extends Serializable, + A2 extends Serializable, + A3 extends Serializable, + A4 extends Serializable> + implements Step2 { + private final StepWithReturnAndFourArgs delegate; + private final A1 arg1; + private final A2 arg2; + private final A3 arg3; + private final A4 arg4; + + StepWithReturnAndFourArgsWrapper( + StepWithReturnAndFourArgs delegate, A1 arg1, A2 arg2, A3 arg3, A4 arg4) { + this.delegate = delegate; + this.arg1 = arg1; + this.arg2 = arg2; + this.arg3 = arg3; + this.arg4 = arg4; + } + + @Override + public R run(JenkinsRule r) throws Throwable { + return delegate.run(r, arg1, arg2, arg3, arg4); + } + } + + // Should not refer to any types outside the JRE. + public static final class Init2 { + + public static void run(Object jenkins) throws Exception { + Object pluginManager = jenkins.getClass().getField("pluginManager").get(jenkins); + ClassLoader uberClassLoader = (ClassLoader) + pluginManager.getClass().getField("uberClassLoader").get(pluginManager); + ClassLoader tests = new URLClassLoader( + Files.readAllLines( + Paths.get(System.getProperty("RealJenkinsExtension.classpath")), + StandardCharsets.UTF_8) + .stream() + .map(Init2::pathToURL) + .toArray(URL[]::new), + uberClassLoader); + tests.loadClass("org.jvnet.hudson.test.junit.jupiter.RealJenkinsExtension$Endpoint") + .getMethod("register") + .invoke(null); + } + + @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "irrelevant") + private static URL pathToURL(String path) { + try { + return Paths.get(path).toUri().toURL(); + } catch (MalformedURLException x) { + throw new IllegalArgumentException(x); + } + } + + static void writeSer(File f, Object o) throws Exception { + try (OutputStream os = new FileOutputStream(f)) { + writeSer(os, o); + } + } + + static void writeSer(OutputStream os, Object o) throws Exception { + try (ObjectOutputStream oos = new ObjectOutputStream(os)) { + oos.writeObject(o); + } + } + + static Object readSer(File f, ClassLoader loader) throws Exception { + try (InputStream is = new FileInputStream(f)) { + return readSer(is, loader); + } + } + + @SuppressFBWarnings(value = "OBJECT_DESERIALIZATION", justification = "irrelevant") + static Object readSer(InputStream is, ClassLoader loader) throws Exception { + try (ObjectInputStream ois = new ObjectInputStream(is) { + @Override + protected Class resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { + if (loader != null) { + try { + return loader.loadClass(desc.getName()); + } catch (ClassNotFoundException x) { + } + } + return super.resolveClass(desc); + } + }) { + return ois.readObject(); + } + } + + private Init2() {} + } + + public static final class Endpoint implements UnprotectedRootAction { + @SuppressWarnings("deprecation") + public static void register() throws Exception { + Jenkins j = Jenkins.get(); + configureLogging(); + j.getActions().add(new Endpoint()); + CrumbExclusion.all().add(new CrumbExclusion() { + @Override + public boolean process(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (request.getPathInfo().startsWith("/RealJenkinsExtension/")) { + chain.doFilter(request, response); + return true; + } + return false; + } + }); + JenkinsRule._configureUpdateCenter(j); + System.err.println("RealJenkinsExtension ready"); + if (!new DisableOnDebug(null).isDebugging()) { + Timer.get().scheduleAtFixedRate(JenkinsRule::dumpThreads, 2, 2, TimeUnit.MINUTES); + } + } + + private static Set loggers = new HashSet<>(); + + private static void configureLogging() { + Level minLevel = Level.INFO; + for (String propertyName : System.getProperties().stringPropertyNames()) { + if (propertyName.startsWith(REAL_JENKINS_EXTENSION_LOGGING)) { + String loggerName = propertyName.substring(REAL_JENKINS_EXTENSION_LOGGING.length()); + Logger logger = Logger.getLogger(loggerName); + Level level = Level.parse(System.getProperty(propertyName)); + if (level.intValue() < minLevel.intValue()) { + minLevel = level; + } + logger.setLevel(level); + loggers.add( + logger); // Keep a ref around, otherwise it is garbage collected and we lose configuration + } + } + // Increase ConsoleHandler level to the finest level we want to log. + if (!loggers.isEmpty()) { + for (Handler h : Logger.getLogger("").getHandlers()) { + if (h instanceof ConsoleHandler) { + h.setLevel(minLevel); + } + } + } + } + + @Override + public String getUrlName() { + return "RealJenkinsExtension"; + } + + @Override + public String getIconFileName() { + return null; + } + + @Override + public String getDisplayName() { + return null; + } + + private final byte[] actualToken = + System.getProperty("RealJenkinsExtension.token").getBytes(StandardCharsets.US_ASCII); + + private void checkToken(String token) { + if (!MessageDigest.isEqual(actualToken, token.getBytes(StandardCharsets.US_ASCII))) { + throw HttpResponses.forbidden(); + } + } + + public void doStatus(@QueryParameter String token) { + System.err.println("Checking status"); + checkToken(token); + } + + /** + * Used to run test methods on a separate thread so that code that uses {@link Stapler#getCurrentRequest2} + * does not inadvertently interact with the request for {@link #doStep} itself. + */ + private static final ExecutorService STEP_RUNNER = Executors.newSingleThreadExecutor(new NamingThreadFactory( + Executors.defaultThreadFactory(), RealJenkinsExtension.class.getName() + ".STEP_RUNNER")); + + @POST + public void doStep(StaplerRequest2 req, StaplerResponse2 rsp) throws Throwable { + InputPayload input = (InputPayload) Init2.readSer(req.getInputStream(), Endpoint.class.getClassLoader()); + checkToken(input.token); + Step2 s = input.step; + URL url = input.url; + String contextPath = input.contextPath; + + Throwable err = null; + Object object = null; + try { + object = STEP_RUNNER + .submit(() -> { + try (CustomJenkinsRule rule = new CustomJenkinsRule(url, contextPath); + ACLContext ctx = ACL.as2(ACL.SYSTEM2)) { + return s.run(rule); + } catch (Throwable t) { + throw new RuntimeException(t); + } + }) + .get(); + } catch (ExecutionException e) { + // Unwrap once for ExecutionException and once for RuntimeException: + err = e.getCause().getCause(); + } catch (CancellationException | InterruptedException e) { + err = e; + } + Init2.writeSer(rsp.getOutputStream(), new OutputPayload(object, err)); + } + + public HttpResponse doExit(@QueryParameter String token) throws IOException, InterruptedException { + checkToken(token); + try (ACLContext ctx = ACL.as2(ACL.SYSTEM2)) { + Jenkins j = Jenkins.get(); + j.doQuietDown(true, 30_000, null, false); // 30s < 60s timeout of stopJenkins + // Cannot use doExit since it requires StaplerRequest2, so would throw an error on older cores: + j.getLifecycle().onStop("RealJenkinsExtension", null); + j.cleanUp(); + new Thread(() -> System.exit(0), "exiting").start(); + } + return HttpResponses.ok(); + } + + public void doTimeout(@QueryParameter String token) { + checkToken(token); + LOGGER.warning("Initiating shutdown"); + STEP_RUNNER.shutdownNow(); + try { + LOGGER.warning("Awaiting termination of steps…"); + STEP_RUNNER.awaitTermination(30, TimeUnit.SECONDS); + LOGGER.warning("…terminated."); + } catch (InterruptedException x) { + x.printStackTrace(); + } + } + } + + public static final class CustomJenkinsRule extends JenkinsRule implements AutoCloseable { + private final URL url; + + public CustomJenkinsRule(URL url, String contextPath) throws Exception { + this.jenkins = Jenkins.get(); + this.url = url; + this.contextPath = contextPath; + if (jenkins.isUsageStatisticsCollected()) { + jenkins.setNoUsageStatistics( + true); // cannot use JenkinsRule._configureJenkinsForTest earlier because it tries to save + // config before loaded + } + if (JenkinsLocationConfiguration.get().getUrl() == null) { + JenkinsLocationConfiguration.get().setUrl(url.toExternalForm()); + } + testDescription = + Description.createSuiteDescription(System.getProperty("RealJenkinsExtension.description")); + env = new TestEnvironment(this.testDescription); + env.pin(); + } + + @Override + public URL getURL() throws IOException { + return url; + } + + @Override + public void close() throws Exception { + env.dispose(); + } + } + + // Copied from hudson.remoting + public static final class ProxyException extends IOException { + ProxyException(Throwable cause) { + super(cause.toString()); + setStackTrace(cause.getStackTrace()); + if (cause.getCause() != null) { + initCause(new ProxyException(cause.getCause())); + } + for (Throwable suppressed : cause.getSuppressed()) { + addSuppressed(new ProxyException(suppressed)); + } + } + + @Override + public String toString() { + return getMessage(); + } + } + + private static class StepsToStep2 implements Step2 { + private final Step[] steps; + + StepsToStep2(Step... steps) { + this.steps = steps; + } + + @Override + public Serializable run(JenkinsRule r) throws Throwable { + for (Step step : steps) { + step.run(r); + } + return null; + } + } + + public static class JenkinsStartupException extends IOException { + public JenkinsStartupException(String message) { + super(message); + } + } + + public static class StepException extends Exception { + StepException(Throwable cause, @CheckForNull String name) { + super( + name != null + ? "Remote step in " + name + " threw an exception: " + cause + : "Remote step threw an exception: " + cause, + cause); + } + } + + private static class InputPayload implements Serializable { + private final String token; + private final Step2 step; + private final URL url; + private final String contextPath; + + InputPayload(String token, Step2 step, URL url) { + this.token = token; + this.step = step; + this.url = url; + this.contextPath = url.getPath().replaceAll("/$", ""); + } + } + + private static class OutputPayload implements Serializable { + private final Object result; + private final ProxyException error; + private final String assumptionFailure; + + OutputPayload(Object result, Throwable error) { + this.result = result; + // TODO use raw error if it seems safe enough + this.error = error != null ? new ProxyException(error) : null; + assumptionFailure = error instanceof TestAbortedException ? 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 static final class SyntheticPlugin { + private final String pkg; + private String shortName; + private String version = "1-SNAPSHOT"; + private final Map headers = new HashMap<>(); + + /** + * Creates a new synthetic plugin builder. + * + * @param exampleClass an example of a class from the Java package containing any classes and resources you want included + * @see RealJenkinsExtension#addSyntheticPlugin + * @see RealJenkinsExtension#createSyntheticPlugin + */ + public SyntheticPlugin(Class exampleClass) { + this(exampleClass.getPackage()); + } + + /** + * Creates a new synthetic plugin builder. + * + * @param pkg the Java package containing any classes and resources you want included + * @see RealJenkinsExtension#addSyntheticPlugin + * @see RealJenkinsExtension#createSyntheticPlugin + */ + public SyntheticPlugin(Package pkg) { + this(pkg.getName()); + } + + /** + * Creates a new synthetic plugin builder. + * + * @param pkg the name of a Java package containing any classes and resources you want included + * @see RealJenkinsExtension#addSyntheticPlugin + * @see RealJenkinsExtension#createSyntheticPlugin + */ + public SyntheticPlugin(String pkg) { + this.pkg = pkg; + shortName = "synthetic-" + this.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: + *

    + *
  • {@code Jenkins-Version: 2.387.3} + *
  • {@code Plugin-Dependencies: structs:325.vcb_307d2a_2782,support-core:1356.vd0f980edfa_46;resolution:=optional} + *
  • {@code Long-Name: My Plugin} + *
+ */ + public SyntheticPlugin header(String key, String value) { + headers.put(key, value); + return this; + } + + void writeTo(File jpi, String defaultJenkinsVersion) throws IOException, URISyntaxException { + var mani = new Manifest(); + var 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 (var entry : headers.entrySet()) { + attr.putValue(entry.getKey(), entry.getValue()); + } + var jar = new ByteArrayOutputStream(); + try (var jos = new JarOutputStream(jar, mani)) { + String pkgSlash = pkg.replace('.', '/'); + URL mainU = RealJenkinsExtension.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(jos, metaInf, "META-INF/", pkg); + } + zip(jos, main, pkgSlash + "/", null); + } + try (var os = new FileOutputStream(jpi); + var jos = new JarOutputStream(os, mani)) { + jos.putNextEntry(new JarEntry("WEB-INF/lib/" + shortName + ".jar")); + jos.write(jar.toByteArray()); + } + LOGGER.info(() -> "Generated " + jpi); + } + + private void zip(ZipOutputStream zos, Path dir, String prefix, @CheckForNull String filter) throws IOException { + try (Stream stream = Files.list(dir)) { + Iterable 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); + } + } + } + } + } +} diff --git a/src/test/java/org/jvnet/hudson/test/BuildWatcherTest.java b/src/test/java/org/jvnet/hudson/test/BuildWatcherTest.java new file mode 100644 index 000000000..28cda88c5 --- /dev/null +++ b/src/test/java/org/jvnet/hudson/test/BuildWatcherTest.java @@ -0,0 +1,58 @@ +/* + * The MIT License + * + * Copyright 2017 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 static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; + +public class BuildWatcherTest { + + @ClassRule + public static final BuildWatcher BUILD_WATCHER = new BuildWatcher(); + + @Rule + public final JenkinsRule j = new JenkinsRule(); + + @Test + public void testBuildWatcher() throws Exception { + PrintStream originalErr = System.err; + + try { + ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + System.setErr(new PrintStream(errContent)); + + j.buildAndAssertSuccess(j.createFreeStyleProject()); + String output = errContent.toString(); + assertThat(output, allOf(containsString("Running as SYSTEM"), containsString("Finished: SUCCESS"))); + } finally { + System.setErr(originalErr); // restore + } + } +} diff --git a/src/test/java/org/jvnet/hudson/test/JenkinsSessionRuleTest.java b/src/test/java/org/jvnet/hudson/test/JenkinsSessionRuleTest.java new file mode 100644 index 000000000..7a3d7f8a9 --- /dev/null +++ b/src/test/java/org/jvnet/hudson/test/JenkinsSessionRuleTest.java @@ -0,0 +1,75 @@ +/* + * The MIT License + * + * Copyright 2025 Jenkins project contributors + * + * 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 static org.junit.Assert.*; + +import java.io.File; +import java.net.URL; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * Test basic behavior of {@link JenkinsSessionRule} + */ +public class JenkinsSessionRuleTest { + + @Rule + public final JenkinsSessionRule rule = new JenkinsSessionRule(); + + @Before + public void before() { + assertNotNull(rule.getHome()); + assertTrue(rule.getHome().exists()); + } + + @After + public void after() { + assertTrue(rule.getHome().exists()); + } + + @Test + public void testRestart() throws Throwable { + assertNotNull(rule.getHome()); + assertTrue(rule.getHome().exists()); + + File[] homes = new File[2]; + URL[] urls = new URL[2]; + + rule.then(r -> { + homes[0] = r.jenkins.getRootDir(); + urls[0] = r.getURL(); + }); + + rule.then(r -> { + homes[1] = r.jenkins.getRootDir(); + urls[1] = r.getURL(); + }); + + assertEquals(homes[0], homes[1]); + assertEquals(urls[0], urls[1]); + } +} diff --git a/src/test/java/org/jvnet/hudson/test/junit/jupiter/BuildWatcherExtensionTest.java b/src/test/java/org/jvnet/hudson/test/junit/jupiter/BuildWatcherExtensionTest.java new file mode 100644 index 000000000..1cc3f5dc9 --- /dev/null +++ b/src/test/java/org/jvnet/hudson/test/junit/jupiter/BuildWatcherExtensionTest.java @@ -0,0 +1,65 @@ +/* + * The MIT License + * + * Copyright 2017 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.junit.jupiter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.jvnet.hudson.test.JenkinsRule; + +@WithJenkins +class BuildWatcherExtensionTest { + + @RegisterExtension + private static final BuildWatcherExtension BUILD_WATCHER = new BuildWatcherExtension(); + + private JenkinsRule j; + + @BeforeEach + void setUp(JenkinsRule rule) { + j = rule; + } + + @Test + void testBuildWatcher() throws Exception { + PrintStream originalErr = System.err; + + try { + ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + System.setErr(new PrintStream(errContent)); + + j.buildAndAssertSuccess(j.createFreeStyleProject()); + String output = errContent.toString(); + assertThat(output, allOf(containsString("Running as SYSTEM"), containsString("Finished: SUCCESS"))); + } finally { + System.setErr(originalErr); // restore + } + } +} diff --git a/src/test/java/org/jvnet/hudson/test/junit/jupiter/InboundAgentExtensionTest.java b/src/test/java/org/jvnet/hudson/test/junit/jupiter/InboundAgentExtensionTest.java new file mode 100644 index 000000000..2ac6a54a8 --- /dev/null +++ b/src/test/java/org/jvnet/hudson/test/junit/jupiter/InboundAgentExtensionTest.java @@ -0,0 +1,60 @@ +/* + * The MIT License + * + * Copyright 2023 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.junit.jupiter; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.PrefixedOutputStream; + +@WithJenkins +class InboundAgentExtensionTest { + + @RegisterExtension + private final InboundAgentExtension inboundAgents = new InboundAgentExtension(); + + private JenkinsRule r; + + @BeforeEach + void setUp(JenkinsRule rule) { + r = rule; + } + + @Test + void waitOnline() throws Exception { + assertTrue(inboundAgents + .createAgent( + r, + InboundAgentExtension.Options.newBuilder() + .color(PrefixedOutputStream.Color.MAGENTA.bold()) + .name("remote") + .build()) + .toComputer() + .isOnline()); + } +} diff --git a/src/test/java/org/jvnet/hudson/test/junit/jupiter/JenkinsSessionExtensionTest.java b/src/test/java/org/jvnet/hudson/test/junit/jupiter/JenkinsSessionExtensionTest.java new file mode 100644 index 000000000..80f1ccf72 --- /dev/null +++ b/src/test/java/org/jvnet/hudson/test/junit/jupiter/JenkinsSessionExtensionTest.java @@ -0,0 +1,84 @@ +/* + * The MIT License + * + * Copyright 2025 Jenkins project contributors + * + * 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.junit.jupiter; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.net.URL; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.jvnet.hudson.test.recipes.LocalData; + +/** + * Test basic behavior of {@link JenkinsSessionExtension} + */ +class JenkinsSessionExtensionTest { + + @RegisterExtension + private final JenkinsSessionExtension extension = new JenkinsSessionExtension(); + + @BeforeEach + void beforeEach() { + assertNotNull(extension.getHome()); + assertTrue(extension.getHome().exists()); + } + + @AfterEach + void afterEach() { + assertTrue(extension.getHome().exists()); + } + + @Test + void testRestart() throws Throwable { + assertNotNull(extension.getHome()); + assertTrue(extension.getHome().exists()); + + File[] homes = new File[2]; + URL[] urls = new URL[2]; + + extension.then(r -> { + homes[0] = r.jenkins.getRootDir(); + urls[0] = r.getURL(); + }); + + extension.then(r -> { + homes[1] = r.jenkins.getRootDir(); + urls[1] = r.getURL(); + }); + + assertEquals(homes[0], homes[1]); + assertEquals(urls[0], urls[1]); + } + + @Test + @LocalData + void testLocalData() throws Throwable { + extension.then(r -> { + assertNotNull(r.jenkins.getItem("localData")); + }); + } +} diff --git a/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionFIPSTest.java b/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionFIPSTest.java new file mode 100644 index 000000000..b58f95078 --- /dev/null +++ b/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionFIPSTest.java @@ -0,0 +1,78 @@ +/* + * The MIT License + * + * Copyright 2024 Olivier Lamy + * + * 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.junit.jupiter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; + +import io.jenkins.test.fips.FIPSTestBundleProvider; +import java.security.KeyStore; +import java.security.Provider; +import java.security.Security; +import java.util.Arrays; +import javax.net.ssl.KeyManagerFactory; +import jenkins.security.FIPS140; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class RealJenkinsExtensionFIPSTest { + + @RegisterExtension + private final RealJenkinsExtension extension = new RealJenkinsExtension() + .prepareHomeLazily(true) + .withDebugPort(4001) + .withDebugServer(false) + .withFIPSEnabled(FIPSTestBundleProvider.get()) + .javaOptions("-Djava.security.debug=properties"); + + @Test + void fipsMode() throws Throwable { + extension.then(r -> { + Provider[] providers = Security.getProviders(); + System.out.println("fipsMode providers:" + Arrays.asList(providers)); + + Class clazz = Thread.currentThread() + .getContextClassLoader() + .loadClass("org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider"); + System.out.println("BouncyCastleFipsProvider class:" + clazz); + + Provider provider = Security.getProvider("BCFIPS"); + assertThat(provider, notNullValue()); + assertThat(provider.getClass().getName(), is("org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider")); + assertThat( + providers[0].getClass().getName(), is("org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider")); + assertThat( + providers[1].getClass().getName(), is("org.bouncycastle.jsse.provider.BouncyCastleJsseProvider")); + assertThat(providers[2].getClass().getName(), is("sun.security.provider.Sun")); + assertThat(KeyStore.getDefaultType(), is("BCFKS")); + assertThat(KeyManagerFactory.getDefaultAlgorithm(), is("PKIX")); + + assertThat(providers.length, is(3)); + + assertThat(FIPS140.useCompliantAlgorithms(), is(true)); + }); + } +} diff --git a/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionHttpsTest.java b/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionHttpsTest.java new file mode 100644 index 000000000..28ff24f7c --- /dev/null +++ b/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionHttpsTest.java @@ -0,0 +1,71 @@ +/* + * 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.junit.jupiter; + +import java.io.IOException; +import java.util.logging.Logger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.PrefixedOutputStream; + +class RealJenkinsExtensionHttpsTest { + private static final Logger LOGGER = Logger.getLogger(RealJenkinsExtensionHttpsTest.class.getName()); + + @RegisterExtension + private final RealJenkinsExtension extension = new RealJenkinsExtension().https(); + + @RegisterExtension + private final InboundAgentExtension iae = new InboundAgentExtension(); + + @BeforeEach + void setUp() throws Throwable { + extension.startJenkins(); + } + + @Test + void runningStepAndUsingHtmlUnit() throws Throwable { + // We can run steps + extension.runRemotely(RealJenkinsExtensionHttpsTest::log); + // web client trusts the cert + try (var wc = extension.createWebClient()) { + wc.getPage(extension.getUrl()); + } + } + + @Test + void inboundAgent() throws Throwable { + var options = InboundAgentExtension.Options.newBuilder() + .name("remote") + .webSocket() + .color(PrefixedOutputStream.Color.YELLOW); + iae.createAgent(extension, options.build()); + } + + private static void log(JenkinsRule r) throws IOException { + LOGGER.info("Running on " + r.getURL().toExternalForm()); + } +} diff --git a/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionSyntheticPluginTest.java b/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionSyntheticPluginTest.java new file mode 100644 index 000000000..2fbbab6ca --- /dev/null +++ b/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionSyntheticPluginTest.java @@ -0,0 +1,82 @@ +/* + * 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.junit.jupiter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import java.util.logging.Level; +import jenkins.model.Jenkins; +import jenkins.security.ClassFilterImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.sample.plugin.CustomJobProperty; +import org.jvnet.hudson.test.sample.plugin.Stuff; + +class RealJenkinsExtensionSyntheticPluginTest { + + @RegisterExtension + private final RealJenkinsExtension extension = new RealJenkinsExtension().prepareHomeLazily(true); + + @Test + void smokes() throws Throwable { + extension.addSyntheticPlugin(new RealJenkinsExtension.SyntheticPlugin(Stuff.class)); + extension.then(RealJenkinsExtensionSyntheticPluginTest::_smokes); + } + + private static void _smokes(JenkinsRule r) throws Throwable { + assertThat( + r.createWebClient().goTo("stuff", "text/plain").getWebResponse().getContentAsString(), + is(Jenkins.get().getLegacyInstanceId())); + } + + @Test + void classFilter() throws Throwable { + extension + .addSyntheticPlugin(new RealJenkinsExtension.SyntheticPlugin(CustomJobProperty.class)) + .withLogger(ClassFilterImpl.class, Level.FINE); + extension.then(r -> { + var p = r.createFreeStyleProject(); + p.addProperty(new CustomJobProperty("expected in XML")); + assertThat(p.getConfigFile().asString(), containsString("expected in XML")); + }); + } + + @Test + void dynamicLoad() throws Throwable { + var pluginJpi = extension.createSyntheticPlugin(new RealJenkinsExtension.SyntheticPlugin(Stuff.class)); + extension.then(r -> { + r.jenkins.pluginManager.dynamicLoad(pluginJpi); + assertThat( + r.createWebClient() + .goTo("stuff", "text/plain") + .getWebResponse() + .getContentAsString(), + is(Jenkins.get().getLegacyInstanceId())); + }); + } +} diff --git a/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionTest.java b/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionTest.java new file mode 100644 index 000000000..19ad6e23c --- /dev/null +++ b/src/test/java/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionTest.java @@ -0,0 +1,508 @@ +/* + * The MIT License + * + * Copyright 2021 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.junit.jupiter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.Mockito.*; + +import hudson.Functions; +import hudson.Launcher; +import hudson.Main; +import hudson.PluginWrapper; +import hudson.model.*; +import hudson.model.listeners.ItemListener; +import hudson.util.PluginServletFilter; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.HttpURLConnection; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import javax.servlet.*; +import jenkins.model.Jenkins; +import jenkins.model.JenkinsLocationConfiguration; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.jvnet.hudson.test.*; +import org.jvnet.hudson.test.recipes.LocalData; +import org.kohsuke.stapler.Stapler; +import org.opentest4j.TestAbortedException; + +class RealJenkinsExtensionTest { + + @RegisterExtension + private final RealJenkinsExtension extension = new RealJenkinsExtension() + .prepareHomeLazily(true) + .withDebugPort(4001) + .withDebugServer(false); + + @Test + void smokes() throws Throwable { + extension.addPlugins("plugins/structs.hpi"); + extension + .extraEnv("SOME_ENV_VAR", "value") + .extraEnv("NOT_SET", null) + .withLogger(Jenkins.class, Level.FINEST) + .then(RealJenkinsExtensionTest::_smokes); + } + + private static void _smokes(JenkinsRule r) throws Throwable { + System.err.println("running in: " + r.jenkins.getRootUrl()); + assertTrue(Main.isUnitTest); + assertNotNull(r.jenkins.getPlugin("structs")); + assertEquals("value", System.getenv("SOME_ENV_VAR")); + } + + @Test + void testReturnObject() throws Throwable { + extension.startJenkins(); + assertThatLocalAndRemoteUrlEquals(); + } + + @Test + void customPrefix() throws Throwable { + extension.withPrefix("/foo").startJenkins(); + assertThat(extension.getUrl().getPath(), equalTo("/foo/")); + assertThatLocalAndRemoteUrlEquals(); + extension.runRemotely(r -> { + assertThat(r.contextPath, equalTo("/foo")); + }); + } + + @Test + void complexPrefix() throws Throwable { + extension.withPrefix("/foo/bar").startJenkins(); + assertThat(extension.getUrl().getPath(), equalTo("/foo/bar/")); + assertThatLocalAndRemoteUrlEquals(); + extension.runRemotely(r -> { + assertThat(r.contextPath, equalTo("/foo/bar")); + }); + } + + @Test + void noPrefix() throws Throwable { + extension.withPrefix("").startJenkins(); + assertThat(extension.getUrl().getPath(), equalTo("/")); + assertThatLocalAndRemoteUrlEquals(); + extension.runRemotely(r -> { + assertThat(r.contextPath, equalTo("")); + }); + } + + @Test + void invalidPrefixes() { + assertThrows(IllegalArgumentException.class, () -> extension.withPrefix("foo")); + assertThrows(IllegalArgumentException.class, () -> extension.withPrefix("/foo/")); + } + + @Test + void ipv6() throws Throwable { + // Use -Djava.net.preferIPv6Addresses=true if dualstack + assumeTrue(InetAddress.getLoopbackAddress() instanceof Inet6Address); + extension.withHost("::1").startJenkins(); + assertThatLocalAndRemoteUrlEquals(); + } + + private void assertThatLocalAndRemoteUrlEquals() throws Throwable { + assertEquals( + extension.getUrl().toExternalForm(), + extension.runRemotely(RealJenkinsExtensionTest::_getJenkinsUrlFromRemote)); + } + + @Test + void testThrowsException() { + assertThat( + assertThrows( + RealJenkinsExtension.StepException.class, + () -> extension.then(RealJenkinsExtensionTest::throwsException)) + .getMessage(), + containsString("IllegalStateException: something is wrong")); + } + + @Test + void killedExternally() throws Exception { + extension.startJenkins(); + try { + extension.proc.destroy(); + } finally { + assertThrows(AssertionError.class, extension::stopJenkins, "nonzero exit code: 143"); + } + } + + private static void throwsException(JenkinsRule r) throws Throwable { + throw new IllegalStateException("something is wrong"); + } + + @Test + void testFilter() throws Throwable { + extension.startJenkins(); + extension.runRemotely(RealJenkinsExtensionTest::_testFilter1); + // Now run another step, body irrelevant just making sure it is not broken + // (do *not* combine into one runRemotely call): + extension.runRemotely(RealJenkinsExtensionTest::_testFilter2); + } + + private static void _testFilter1(JenkinsRule jenkinsRule) throws Throwable { + PluginServletFilter.addFilter(new Filter() { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + String fake = request.getParameter("fake"); + chain.doFilter(request, response); + } + + @Override + public void destroy() {} + + @Override + public void init(FilterConfig filterConfig) throws ServletException {} + }); + } + + private static void _testFilter2(JenkinsRule jenkinsRule) throws Throwable {} + + @Test + void chainedSteps() throws Throwable { + extension.startJenkins(); + extension.runRemotely(RealJenkinsExtensionTest::chainedSteps1, RealJenkinsExtensionTest::chainedSteps2); + } + + private static void chainedSteps1(JenkinsRule jenkinsRule) throws Throwable { + System.setProperty("key", "xxx"); + } + + private static void chainedSteps2(JenkinsRule jenkinsRule) throws Throwable { + assertEquals("xxx", System.getProperty("key")); + } + + @Test + void error() { + boolean erred = false; + try { + extension.then(RealJenkinsExtensionTest::_error); + } catch (Throwable t) { + erred = true; + t.printStackTrace(); + assertThat(Functions.printThrowable(t), containsString("java.lang.AssertionError: oops")); + } + assertTrue(erred); + } + + private static void _error(JenkinsRule r) throws Throwable { + assert false : "oops"; + } + + @Test + void agentBuild() throws Throwable { + try (var tailLog = new TailLog(extension, "p", 1).withColor(PrefixedOutputStream.Color.MAGENTA)) { + extension.then(r -> { + var p = r.createFreeStyleProject("p"); + var ran = new AtomicBoolean(); + p.getBuildersList().add(TestBuilder.of((build, launcher, listener) -> ran.set(true))); + p.setAssignedNode(r.createOnlineSlave()); + r.buildAndAssertSuccess(p); + assertTrue(ran.get()); + }); + tailLog.waitForCompletion(); + } + } + + @Test + void htmlUnit() throws Throwable { + extension.startJenkins(); + extension.runRemotely(r -> { + r.jenkins.setSecurityRealm(r.createDummySecurityRealm()); + r.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER) + .everywhere() + .to("admin")); + var p = r.createFreeStyleProject("p"); + p.setDescription("hello"); + }); + System.err.println("running against " + extension.getUrl()); + extension.runRemotely(r -> { + var p = r.jenkins.getItemByFullName("p", FreeStyleProject.class); + r.submit(r.createWebClient().login("admin").getPage(p, "configure").getFormByName("config")); + assertEquals("hello", p.getDescription()); + }); + } + + private static String _getJenkinsUrlFromRemote(JenkinsRule r) { + return r.jenkins.getRootUrl(); + } + + @LocalData + @Test + void localData() throws Throwable { + extension.then(RealJenkinsExtensionTest::_localData); + } + + private static void _localData(JenkinsRule r) throws Throwable { + assertThat(r.jenkins.getItems().stream().map(Item::getName).toArray(), arrayContainingInAnyOrder("x")); + } + + @Test + void restart() throws Throwable { + extension.then(r -> { + assertEquals(r.jenkins.getRootUrl(), r.getURL().toString()); + Files.writeString( + r.jenkins.getRootDir().toPath().resolve("url.txt"), + r.getURL().toString(), + StandardCharsets.UTF_8); + r.jenkins.getExtensionList(ItemListener.class).add(0, new ShutdownListener()); + }); + extension.then(r -> { + assertEquals(r.jenkins.getRootUrl(), r.getURL().toString()); + assertEquals( + r.jenkins.getRootUrl(), + Files.readString(r.jenkins.getRootDir().toPath().resolve("url.txt"), StandardCharsets.UTF_8)); + assertTrue(new File(Jenkins.get().getRootDir(), "RealJenkinsExtension-ran-cleanUp").exists()); + }); + } + + private static class ShutdownListener extends ItemListener { + private final String fileName = "RealJenkinsExtension-ran-cleanUp"; + + @Override + public void onBeforeShutdown() { + try { + new File(Jenkins.get().getRootDir(), fileName).createNewFile(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } + + @Test + void stepsDoNotRunOnHttpWorkerThread() throws Throwable { + extension.then(RealJenkinsExtensionTest::_stepsDoNotRunOnHttpWorkerThread); + } + + private static void _stepsDoNotRunOnHttpWorkerThread(JenkinsRule r) throws Throwable { + assertNull(Stapler.getCurrentRequest()); + } + + @Test + void stepsDoNotOverwriteJenkinsLocationConfigurationIfOtherwiseSet() throws Throwable { + extension.then(r -> { + assertNotNull(JenkinsLocationConfiguration.get().getUrl()); + JenkinsLocationConfiguration.get().setUrl("https://example.com/"); + }); + extension.then(r -> { + assertEquals( + "https://example.com/", JenkinsLocationConfiguration.get().getUrl()); + }); + } + + @Test + void test500Errors() throws IOException { + HttpURLConnection conn = mock(HttpURLConnection.class); + when(conn.getResponseCode()).thenReturn(500); + assertThrows(RealJenkinsExtension.JenkinsStartupException.class, () -> RealJenkinsExtension.checkResult(conn)); + } + + @Test + void test503Errors() throws IOException { + HttpURLConnection conn = mock(HttpURLConnection.class); + when(conn.getResponseCode()).thenReturn(503); + when(conn.getErrorStream()) + .thenReturn(new ByteArrayInputStream("Jenkins Custom Error".getBytes(StandardCharsets.UTF_8))); + + String s = RealJenkinsExtension.checkResult(conn); + + assertThat(s, is("Jenkins Custom Error")); + } + + @Test + void test200Ok() throws IOException { + HttpURLConnection conn = mock(HttpURLConnection.class); + when(conn.getResponseCode()).thenReturn(200); + when(conn.getInputStream()) + .thenReturn(new ByteArrayInputStream("blah blah blah".getBytes(StandardCharsets.UTF_8))); + + String s = RealJenkinsExtension.checkResult(conn); + + verify(conn, times(1)).getInputStream(); + assertThat(s, nullValue()); + } + + /** + * plugins/failure.hpi + * Plugin that has this: + * + * @Initializer(after=JOB_LOADED) public static void init() throws IOException { + * throw new IOException("oops"); + * } + */ + @Test + void whenUsingFailurePlugin() throws Throwable { + RealJenkinsExtension.JenkinsStartupException jse = assertThrows( + RealJenkinsExtension.JenkinsStartupException.class, + () -> extension.addPlugins("plugins/failure.hpi").startJenkins()); + assertThat(jse.getMessage(), containsString("Error
java.io.IOException: oops"));
+    }
+
+    @Test
+    void whenUsingWrongJavaHome() throws Throwable {
+        IOException ex = assertThrows(
+                IOException.class, () -> extension.withJavaHome("/noexists").startJenkins());
+        assertThat(
+                ex.getMessage(),
+                containsString(File.separator + "noexists" + File.separator + "bin" + File.separator + "java"));
+    }
+
+    @Test
+    void smokesJavaHome() throws Throwable {
+        String altJavaHome = System.getProperty("java.home");
+        extension.addPlugins("plugins/structs.hpi");
+        extension
+                .extraEnv("SOME_ENV_VAR", "value")
+                .extraEnv("NOT_SET", null)
+                .withJavaHome(altJavaHome)
+                .withLogger(Jenkins.class, Level.FINEST)
+                .then(RealJenkinsExtensionTest::_smokes);
+    }
+
+    @Issue("https://github.com/jenkinsci/jenkins-test-harness/issues/359")
+    @Test
+    void assumptions() throws Throwable {
+        assertThat(
+                assertThrows(TestAbortedException.class, () -> extension.then(RealJenkinsExtensionTest::_assumptions1))
+                        .getMessage(),
+                is("Assumption failed: assumption is not true"));
+        assertThat(
+                assertThrows(TestAbortedException.class, () -> extension.then(RealJenkinsExtensionTest::_assumptions2))
+                        .getMessage(),
+                is("Assumption failed: oops"));
+    }
+
+    private static void _assumptions1(JenkinsRule r) {
+        assumeTrue(2 + 2 == 5);
+    }
+
+    private static void _assumptions2(JenkinsRule r) {
+        assumeTrue(2 + 2 == 5, "oops");
+    }
+
+    @Test
+    void timeoutDuringStep() throws Throwable {
+        extension.withTimeout(10);
+        assertThat(
+                Functions.printThrowable(assertThrows(
+                        RealJenkinsExtension.StepException.class,
+                        () -> extension.then(RealJenkinsExtensionTest::hangs))),
+                containsString(
+                        "\tat " + RealJenkinsExtensionTest.class.getName() + ".hangs(RealJenkinsExtensionTest.java:"));
+    }
+
+    private static void hangs(JenkinsRule r) throws Throwable {
+        System.err.println("Hanging step…");
+        Thread.sleep(Long.MAX_VALUE);
+    }
+
+    @Test
+    void noDetachedPlugins() throws Throwable {
+        // we should be the only plugin in Jenkins.
+        extension.then(RealJenkinsExtensionTest::_noDetachedPlugins);
+    }
+
+    private static void _noDetachedPlugins(JenkinsRule r) throws Throwable {
+        // only RealJenkinsRuleInit should be present
+        List plugins = r.jenkins.getPluginManager().getPlugins();
+        assertThat(plugins, hasSize(1));
+        assertThat(plugins.get(0).getShortName(), is("RealJenkinsRuleInit"));
+    }
+
+    @Test
+    void safeExit() throws Throwable {
+        extension.then(r -> {
+            var p = r.createFreeStyleProject();
+            p.getBuildersList().add(TestBuilder.of((build, launcher, listener) -> Thread.sleep(Long.MAX_VALUE)));
+            p.scheduleBuild2(0).waitForStart();
+        });
+    }
+
+    @Test
+    void xStreamSerializable() throws Throwable {
+        extension.startJenkins();
+        // Neither ParametersDefinitionProperty nor ParametersAction could be passed directly.
+        // (In this case, ParameterDefinition and ParameterValue could have been used raw.
+        // But even List cannot be typed here, only e.g. ArrayList.)
+        var prop = XStreamSerializable.of(new ParametersDefinitionProperty(new StringParameterDefinition("X", "dflt")));
+        // Static method handle idiom:
+        assertThat(
+                extension
+                        .runRemotely(RealJenkinsExtensionTest::_xStreamSerializable, prop)
+                        .object()
+                        .getAllParameters(),
+                hasSize(1));
+        // Lambda idiom:
+        assertThat(
+                extension
+                        .runRemotely(r -> {
+                            var p = r.createFreeStyleProject();
+                            p.addProperty(prop.object());
+                            var b = r.buildAndAssertSuccess(p);
+                            return XStreamSerializable.of(b.getAction(ParametersAction.class));
+                        })
+                        .object()
+                        .getAllParameters(),
+                hasSize(1));
+    }
+
+    private static XStreamSerializable _xStreamSerializable(
+            JenkinsRule r, XStreamSerializable> prop) throws Throwable {
+        var p = r.createFreeStyleProject();
+        p.addProperty(prop.object());
+        var b = r.buildAndAssertSuccess(p);
+        return XStreamSerializable.of(b.getAction(ParametersAction.class));
+    }
+
+    @Disabled(
+            "inner class inside lambda breaks with an opaque NotSerializableException: RealJenkinsExtensionTest; use TestBuilder.of instead")
+    @Test
+    void lambduh() throws Throwable {
+        extension.then(r -> {
+            r.createFreeStyleProject().getBuildersList().add(new TestBuilder() {
+                @Override
+                public boolean perform(AbstractBuild build, Launcher launcher, BuildListener listener)
+                        throws InterruptedException, IOException {
+                    return true;
+                }
+            });
+        });
+    }
+}
diff --git a/src/test/resources/org/jvnet/hudson/test/junit/jupiter/JenkinsSessionExtensionTest/testLocalData/jobs/localData/config.xml b/src/test/resources/org/jvnet/hudson/test/junit/jupiter/JenkinsSessionExtensionTest/testLocalData/jobs/localData/config.xml
new file mode 100644
index 000000000..a1e54a01d
--- /dev/null
+++ b/src/test/resources/org/jvnet/hudson/test/junit/jupiter/JenkinsSessionExtensionTest/testLocalData/jobs/localData/config.xml
@@ -0,0 +1 @@
+
diff --git a/src/test/resources/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionTest/localData/jobs/x/config.xml b/src/test/resources/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionTest/localData/jobs/x/config.xml
new file mode 100644
index 000000000..a1e54a01d
--- /dev/null
+++ b/src/test/resources/org/jvnet/hudson/test/junit/jupiter/RealJenkinsExtensionTest/localData/jobs/x/config.xml
@@ -0,0 +1 @@
+