diff --git a/plugin/pom.xml b/plugin/pom.xml
index 12bf849e..92f5bd20 100644
--- a/plugin/pom.xml
+++ b/plugin/pom.xml
@@ -49,8 +49,47 @@
+
+ org.jenkins-ci.tools
+ maven-hpi-plugin
+ 3.3
+
+ ../client/target
+
+
+
+ 2.13
+
+
+
+
+ org.jenkins-ci
+ symbol-annotation
+ 1.5
+ test
+
+
+ org.jenkins-ci.plugins
+ scm-api
+ 1.3
+ test
+
+
+ org.jenkins-ci.plugins
+ structs
+ 1.5
+ test
+
+
+ org.jenkins-ci.plugins
+ script-security
+ 1.24
+ test
+
+
+
com.google.code.findbugs
@@ -64,5 +103,54 @@
3.0.1
provided
+
+ org.jenkins-ci.plugins.workflow
+ workflow-api
+ 2.12
+ test
+
+
+ org.jenkins-ci.plugins.workflow
+ workflow-basic-steps
+ 2.3
+ test
+
+
+ org.jenkins-ci.plugins.workflow
+ workflow-cps
+ 2.28
+ test
+
+
+ org.jenkins-ci.plugins.workflow
+ workflow-durable-task-step
+ 2.4
+ test
+
+
+ org.jenkins-ci.plugins.workflow
+ workflow-job
+ 2.9
+ test
+
+
+ org.jenkins-ci.plugins.workflow
+ workflow-step-api
+ 2.7
+ test
+
+
+ org.jenkins-ci.plugins.workflow
+ workflow-support
+ ${workflow-support-plugin.version}
+ test
+
+
+ org.jenkins-ci.plugins.workflow
+ workflow-support
+ ${workflow-support-plugin.version}
+ tests
+ test
+
diff --git a/plugin/src/test/java/hudson/plugins/swarm/PipelineJobTest.java b/plugin/src/test/java/hudson/plugins/swarm/PipelineJobTest.java
new file mode 100644
index 00000000..6577d550
--- /dev/null
+++ b/plugin/src/test/java/hudson/plugins/swarm/PipelineJobTest.java
@@ -0,0 +1,205 @@
+package hudson.plugins.swarm;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import hudson.Functions;
+import hudson.model.Computer;
+import hudson.model.Node;
+import hudson.plugins.swarm.test.ProcessDestroyer;
+import hudson.plugins.swarm.test.TestUtils;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
+import org.jenkinsci.plugins.workflow.job.WorkflowJob;
+import org.jenkinsci.plugins.workflow.job.WorkflowRun;
+import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep;
+import org.junit.Assume;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.jvnet.hudson.test.BuildWatcher;
+import org.jvnet.hudson.test.RestartableJenkinsRule;
+
+public class PipelineJobTest {
+
+ @Rule public RestartableJenkinsRule story = new RestartableJenkinsRule();
+
+ @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher();
+
+ @ClassRule public static TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private final ProcessDestroyer processDestroyer = new ProcessDestroyer();
+
+ /** Executes a shell script build on a Swarm Client agent. */
+ @Test
+ public void buildShellScript() throws Exception {
+ story.then(
+ s -> {
+ Node node =
+ TestUtils.createSwarmClient(story.j, processDestroyer, temporaryFolder);
+
+ WorkflowJob project = story.j.createProject(WorkflowJob.class);
+ project.setConcurrentBuild(false);
+ project.setDefinition(new CpsFlowDefinition(getFlow(node, 0), true));
+
+ WorkflowRun build = story.j.buildAndAssertSuccess(project);
+ story.j.assertLogContains("ON_SWARM_CLIENT=true", build);
+ tearDown();
+ });
+ }
+
+ /**
+ * Executes a shell script build on a Swarm Client agent that has disconnected while the Jenkins
+ * master is still running.
+ */
+ @Test
+ public void buildShellScriptAfterDisconnect() throws Exception {
+ story.then(
+ s -> {
+ Node node =
+ TestUtils.createSwarmClient(story.j, processDestroyer, temporaryFolder);
+
+ WorkflowJob project = story.j.createProject(WorkflowJob.class);
+ project.setConcurrentBuild(false);
+ project.setDefinition(new CpsFlowDefinition(getFlow(node, 1), true));
+
+ WorkflowRun build = project.scheduleBuild2(0).waitForStart();
+ SemaphoreStep.waitForStart("wait-0/1", build);
+ tearDown();
+
+ TestUtils.createSwarmClient(
+ node.getNodeName(), story.j, processDestroyer, temporaryFolder);
+ SemaphoreStep.success("wait-0/1", null);
+ story.j.assertBuildStatusSuccess(story.j.waitForCompletion(build));
+ story.j.assertLogContains("ON_SWARM_CLIENT=true", build);
+ tearDown();
+ });
+ }
+
+ /** Same as the preceding test, but waits in "sh" rather than "node." */
+ @Test
+ public void buildShellScriptAcrossDisconnect() throws Exception {
+ Assume.assumeFalse(
+ "TODO not sure how to write a corresponding batch script", Functions.isWindows());
+ story.then(
+ s -> {
+ Node node =
+ TestUtils.createSwarmClient(story.j, processDestroyer, temporaryFolder);
+
+ WorkflowJob project = story.j.createProject(WorkflowJob.class);
+ File f1 = new File(story.j.jenkins.getRootDir(), "f1");
+ File f2 = new File(story.j.jenkins.getRootDir(), "f2");
+ new FileOutputStream(f1).close();
+ project.setConcurrentBuild(false);
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("node('" + node.getNodeName() + "') {\n");
+ sb.append(
+ " sh 'touch \""
+ + f2
+ + "\"; while [ -f \""
+ + f1
+ + "\" ]; do sleep 1; done; echo finished waiting; rm \""
+ + f2
+ + "\"'\n");
+ sb.append(" echo 'OK, done'\n");
+ sb.append("}\n");
+ project.setDefinition(new CpsFlowDefinition(sb.toString(), true));
+
+ WorkflowRun build = project.scheduleBuild2(0).waitForStart();
+ while (!f2.isFile()) {
+ Thread.sleep(100);
+ }
+ assertTrue(build.isBuilding());
+ Computer computer = node.toComputer();
+ assertNotNull(computer);
+ tearDown();
+ while (computer.isOnline()) {
+ Thread.sleep(100);
+ }
+
+ TestUtils.createSwarmClient(
+ node.getNodeName(), story.j, processDestroyer, temporaryFolder);
+ while (computer.isOffline()) {
+ Thread.sleep(100);
+ }
+ assertTrue(f2.isFile());
+ assertTrue(f1.delete());
+ while (f2.isFile()) {
+ Thread.sleep(100);
+ }
+
+ story.j.assertBuildStatusSuccess(story.j.waitForCompletion(build));
+ story.j.assertLogContains("finished waiting", build);
+ story.j.assertLogContains("OK, done", build);
+ tearDown();
+ });
+ }
+
+ /**
+ * Starts a Jenkins job on a Swarm Client agent, restarts Jenkins while the job is running, and
+ * verifies that the job continues running on the same agent after Jenkins has been restarted.
+ */
+ @Test
+ public void buildShellScriptAfterRestart() throws Exception {
+ Assume.assumeNotNull(
+ System.getProperty("port"), "This test requires a fixed port to be available.");
+
+ story.then(
+ s -> {
+ // "-deleteExistingClients" is needed so that the Swarm Client can connect
+ // after the restart.
+ Node node =
+ TestUtils.createSwarmClient(
+ story.j,
+ processDestroyer,
+ temporaryFolder,
+ "-deleteExistingClients");
+
+ WorkflowJob project = story.j.createProject(WorkflowJob.class, "test");
+ project.setConcurrentBuild(false);
+ project.setDefinition(new CpsFlowDefinition(getFlow(node, 1), true));
+
+ WorkflowRun build = project.scheduleBuild2(0).waitForStart();
+ SemaphoreStep.waitForStart("wait-0/1", build);
+ });
+ story.then(
+ s -> {
+ SemaphoreStep.success("wait-0/1", null);
+ WorkflowJob project =
+ story.j.jenkins.getItemByFullName("test", WorkflowJob.class);
+ assertNotNull(project);
+ WorkflowRun build = project.getBuildByNumber(1);
+ story.j.assertBuildStatusSuccess(story.j.waitForCompletion(build));
+ story.j.assertLogContains("ON_SWARM_CLIENT=true", build);
+ tearDown();
+ });
+ }
+
+ private static String getFlow(Node node, int numSemaphores) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("node('" + node.getNodeName() + "') {\n");
+ for (int i = 0; i < numSemaphores; i++) {
+ sb.append(" semaphore 'wait-" + i + "'\n");
+ }
+ // TODO: Once JENKINS-41854 is fixed, remove the next two lines.
+ sb.append("}\n");
+ sb.append("node('" + node.getNodeName() + "') {\n");
+ sb.append(
+ " isUnix() ? sh('echo ON_SWARM_CLIENT=$ON_SWARM_CLIENT') : bat('echo ON_SWARM_CLIENT=%ON_SWARM_CLIENT%')");
+ sb.append("}\n");
+
+ return sb.toString();
+ }
+
+ private void tearDown() throws IOException {
+ try {
+ processDestroyer.clean();
+ } catch (InterruptedException e) {
+ e.printStackTrace(System.err);
+ }
+ }
+}
diff --git a/plugin/src/test/java/hudson/plugins/swarm/SwarmClientIntegrationTest.java b/plugin/src/test/java/hudson/plugins/swarm/SwarmClientIntegrationTest.java
new file mode 100644
index 00000000..9b89e0b8
--- /dev/null
+++ b/plugin/src/test/java/hudson/plugins/swarm/SwarmClientIntegrationTest.java
@@ -0,0 +1,56 @@
+package hudson.plugins.swarm;
+
+import hudson.Functions;
+import hudson.model.FreeStyleBuild;
+import hudson.model.FreeStyleProject;
+import hudson.model.Node;
+import hudson.plugins.swarm.test.ProcessDestroyer;
+import hudson.plugins.swarm.test.TestUtils;
+import hudson.tasks.BatchFile;
+import hudson.tasks.CommandInterpreter;
+import hudson.tasks.Shell;
+import java.io.IOException;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.jvnet.hudson.test.BuildWatcher;
+import org.jvnet.hudson.test.JenkinsRule;
+
+public class SwarmClientIntegrationTest {
+
+ @Rule public JenkinsRule j = new JenkinsRule();
+
+ @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher();
+
+ @ClassRule public static TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private final ProcessDestroyer processDestroyer = new ProcessDestroyer();
+
+ /** Executes a shell script build on a Swarm Client agent. */
+ @Test
+ public void buildShellScript() throws Exception {
+ Node node = TestUtils.createSwarmClient(j, processDestroyer, temporaryFolder);
+
+ FreeStyleProject project = j.createFreeStyleProject();
+ project.setConcurrentBuild(false);
+ project.setAssignedNode(node);
+ CommandInterpreter command =
+ Functions.isWindows()
+ ? new BatchFile("echo ON_SWARM_CLIENT=%ON_SWARM_CLIENT%")
+ : new Shell("echo ON_SWARM_CLIENT=$ON_SWARM_CLIENT");
+ project.getBuildersList().add(command);
+
+ FreeStyleBuild build = j.buildAndAssertSuccess(project);
+ j.assertLogContains("ON_SWARM_CLIENT=true", build);
+ tearDown();
+ }
+
+ private void tearDown() throws IOException {
+ try {
+ processDestroyer.clean();
+ } catch (InterruptedException e) {
+ e.printStackTrace(System.err);
+ }
+ }
+}
diff --git a/plugin/src/test/java/hudson/plugins/swarm/test/ProcessDestroyer.java b/plugin/src/test/java/hudson/plugins/swarm/test/ProcessDestroyer.java
new file mode 100644
index 00000000..c69ec298
--- /dev/null
+++ b/plugin/src/test/java/hudson/plugins/swarm/test/ProcessDestroyer.java
@@ -0,0 +1,27 @@
+package hudson.plugins.swarm.test;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Records processes started during Swarm Client integration tests so that they can be destroyed at
+ * the end of each test method.
+ */
+public final class ProcessDestroyer {
+
+ private final Set processes = new HashSet<>();
+
+ /** Record a spawned process for later cleanup. */
+ public void record(Process process) {
+ processes.add(process);
+ }
+
+ /** Clean up all processes that were spawned during the test. */
+ public void clean() throws InterruptedException {
+ for (Process process : processes) {
+ process.destroy();
+ process.waitFor();
+ }
+ processes.clear();
+ }
+}
diff --git a/plugin/src/test/java/hudson/plugins/swarm/test/TestUtils.java b/plugin/src/test/java/hudson/plugins/swarm/test/TestUtils.java
new file mode 100644
index 00000000..608f024f
--- /dev/null
+++ b/plugin/src/test/java/hudson/plugins/swarm/test/TestUtils.java
@@ -0,0 +1,108 @@
+package hudson.plugins.swarm.test;
+
+import static org.hamcrest.Matchers.greaterThan;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import hudson.model.Computer;
+import hudson.model.Node;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import jenkins.model.Jenkins;
+import org.apache.commons.io.FileUtils;
+import org.junit.rules.TemporaryFolder;
+import org.jvnet.hudson.test.JenkinsRule;
+
+/** Utilities for testing the Swarm Plugin */
+public class TestUtils {
+
+ /** Download the Swarm Client from the given Jenkins URL into the given temporary directory. */
+ private static File download(URL jenkinsUrl, File output) throws Exception {
+ URL input =
+ jenkinsUrl.toURI().resolve(new URI(null, "swarm/swarm-client.jar", null)).toURL();
+ FileUtils.copyURLToFile(input, output);
+
+ assertTrue(output.isFile());
+ assertThat(output.length(), greaterThan(1000L));
+ return output;
+ }
+
+ /** Wait for the agent with the given name to come online against the given Jenkins instance. */
+ private static Computer waitOnline(String agentName, Jenkins jenkins)
+ throws InterruptedException, IOException {
+ Computer computer = jenkins.getComputer(agentName);
+ while (computer == null) {
+ Thread.sleep(100);
+ computer = jenkins.getComputer(agentName);
+ }
+
+ while (!computer.isOnline()) {
+ Thread.sleep(100);
+ }
+
+ return computer;
+ }
+
+ /**
+ * Create a new Swarm Client agent on the local host and wait for it to come online before
+ * returning.
+ */
+ public static Node createSwarmClient(
+ JenkinsRule j,
+ ProcessDestroyer processDestroyer,
+ TemporaryFolder temporaryFolder,
+ String... args)
+ throws Exception {
+ String agentName = "agent" + j.jenkins.getNodes().size();
+ return createSwarmClient(agentName, j, processDestroyer, temporaryFolder, args);
+ }
+
+ /**
+ * Create a new Swarm Client agent on the local host and wait for it to come online before
+ * returning.
+ */
+ public static Node createSwarmClient(
+ String agentName,
+ JenkinsRule j,
+ ProcessDestroyer processDestroyer,
+ TemporaryFolder temporaryFolder,
+ String... args)
+ throws Exception {
+ File swarmClientJar = download(j.getURL(), temporaryFolder.newFile("swarm-client.jar"));
+
+ List command = new ArrayList<>();
+ command.add(
+ System.getProperty("java.home") + File.separator + "bin" + File.separator + "java");
+ command.add("-Djava.awt.headless=true");
+ command.add("-jar");
+ command.add(swarmClientJar.toString());
+ command.add("-name");
+ command.add(agentName);
+ command.add("-master");
+ command.add(j.getURL().toString());
+ // "-disableClientsUniqueId" is needed so that we can call getComputer() in the waitOnline()
+ // method.
+ command.add("-disableClientsUniqueId");
+ Collections.addAll(command, args);
+
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.environment().put("ON_SWARM_CLIENT", "true");
+ pb.redirectOutput(temporaryFolder.newFile("stdout.log"));
+ pb.redirectError(temporaryFolder.newFile("stderr.log"));
+ Process process = pb.start();
+ processDestroyer.record(process);
+
+ Computer computer = waitOnline(agentName, j.jenkins);
+ assertNotNull(computer);
+ assertTrue(computer.isOnline());
+ Node node = computer.getNode();
+ assertNotNull(node);
+ return node;
+ }
+}