├── client ├── .gitignore ├── src │ ├── main │ │ └── java │ │ │ └── hudson │ │ │ └── plugins │ │ │ └── swarm │ │ │ ├── SoftLabelUpdateException.java │ │ │ ├── ConfigurationException.java │ │ │ ├── RetryException.java │ │ │ ├── RetryBackOffStrategyOptionHandler.java │ │ │ ├── RetryBackOffStrategy.java │ │ │ ├── RestrictiveEntityResolver.java │ │ │ ├── ModeOptionHandler.java │ │ │ ├── XmlUtils.java │ │ │ ├── YamlConfig.java │ │ │ ├── Options.java │ │ │ ├── LabelFileWatcher.java │ │ │ ├── Client.java │ │ │ └── SwarmClient.java │ ├── spotbugs │ │ └── excludesFilter.xml │ └── test │ │ └── java │ │ └── hudson │ │ └── plugins │ │ └── swarm │ │ ├── RetryBackOffStrategyTest.java │ │ ├── ClientTest.java │ │ ├── SwarmClientTest.java │ │ └── YamlConfigTest.java ├── svc-hudson-swarm-client ├── logging.properties ├── smf.xml └── pom.xml ├── .github ├── CODEOWNERS ├── release-drafter.yml ├── dependabot.yml └── workflows │ ├── release-drafter.yml │ └── jenkins-security-scan.yml ├── release.sh ├── .mvn ├── maven.config └── extensions.xml ├── docs ├── images │ ├── matrixBasedSecurity.png │ ├── roleBasedStrategyAssign.png │ ├── roleBasedStrategyManage.png │ └── projectBasedMatrixAuthorizationStrategy.png ├── proxy.adoc ├── prometheus.adoc ├── configfile.adoc ├── logging.adoc └── security.adoc ├── .git-blame-ignore-revs ├── Jenkinsfile ├── plugin ├── src │ ├── main │ │ ├── resources │ │ │ ├── index.jelly │ │ │ └── hudson │ │ │ │ └── plugins │ │ │ │ └── swarm │ │ │ │ └── SwarmSlave │ │ │ │ ├── configure-entries_ja.properties │ │ │ │ └── configure-entries.jelly │ │ └── java │ │ │ └── hudson │ │ │ └── plugins │ │ │ └── swarm │ │ │ ├── KeepSwarmClientNodeProperty.java │ │ │ ├── DownloadClientAction.java │ │ │ ├── SwarmLauncher.java │ │ │ ├── SwarmSlave.java │ │ │ └── PluginImpl.java │ └── test │ │ └── java │ │ └── hudson │ │ └── plugins │ │ └── swarm │ │ ├── PipelineJobRestartTest.java │ │ ├── PipelineJobTest.java │ │ ├── AuthorizationStrategyTest.java │ │ └── test │ │ ├── GlobalSecurityConfigurationBuilder.java │ │ └── SwarmClientRule.java └── pom.xml ├── .gitignore ├── .mailmap ├── pom.xml ├── README.adoc └── CHANGELOG.adoc /client/.gitignore: -------------------------------------------------------------------------------- 1 | dependency-reduced-pom.xml 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jenkinsci/swarm-plugin-developers 2 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mvn release:prepare release:perform 4 | -------------------------------------------------------------------------------- /.mvn/maven.config: -------------------------------------------------------------------------------- 1 | -Pconsume-incrementals 2 | -Pmight-produce-incrementals 3 | -------------------------------------------------------------------------------- /docs/images/matrixBasedSecurity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/swarm-plugin/HEAD/docs/images/matrixBasedSecurity.png -------------------------------------------------------------------------------- /docs/images/roleBasedStrategyAssign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/swarm-plugin/HEAD/docs/images/roleBasedStrategyAssign.png -------------------------------------------------------------------------------- /docs/images/roleBasedStrategyManage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/swarm-plugin/HEAD/docs/images/roleBasedStrategyManage.png -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # .git-blame-ignore-revs 2 | # Enable Spotless for code formatting (#549) 3 | 6052458c68fef20972059c97e16bd25eb3fbb173 4 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | buildPlugin(useContainerAgent: true, configurations: [ 2 | [platform: 'linux', jdk: 21], 3 | [platform: 'windows', jdk: 17], 4 | ]) 5 | -------------------------------------------------------------------------------- /docs/images/projectBasedMatrixAuthorizationStrategy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenkinsci/swarm-plugin/HEAD/docs/images/projectBasedMatrixAuthorizationStrategy.png -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/jenkinsci/.github/blob/master/.github/release-drafter.adoc 2 | _extends: .github 3 | tag-template: swarm-plugin-$NEXT_MINOR_VERSION 4 | -------------------------------------------------------------------------------- /plugin/src/main/resources/index.jelly: -------------------------------------------------------------------------------- 1 | 2 |
3 | The Swarm plugin enables nodes to join a nearby Jenkins controller, thereby forming an ad-hoc cluster. 4 |
5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | 3 | # mvn hpi:run 4 | work 5 | 6 | # IntelliJ IDEA project files 7 | *.iml 8 | *.iws 9 | *.ipr 10 | .idea 11 | 12 | # Eclipse project files 13 | .settings 14 | .classpath 15 | .project 16 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/SoftLabelUpdateException.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | public class SoftLabelUpdateException extends Exception { 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | public SoftLabelUpdateException(String s) { 8 | super(s); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Peter Jönsson 2 | Seiji Sogabe 3 | Kohsuke Kawaguchi 4 | Nico Mommaerts 5 | Mark Waite 6 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/ConfigurationException.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | /** Signals an error in the configuration. */ 4 | public class ConfigurationException extends Exception { 5 | private static final long serialVersionUID = 1L; 6 | 7 | public ConfigurationException(String message) { 8 | super(message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.mvn/extensions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | io.jenkins.tools.incrementals 4 | git-changelist-maven-extension 5 | 1.13 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "maven" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | ignore: 10 | - dependency-name: "org.jenkins-ci.plugins:role-strategy" 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | -------------------------------------------------------------------------------- /client/svc-hudson-swarm-client: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # shell script called by SMF to run a Hudson swarm client as a service. 3 | # placed in /lib/svc/method 4 | vmopts="$(svcprop -p jenkins/jvm_options $SMF_FMRI)" 5 | opts="$(svcprop -p jenkins/options $SMF_FMRI)" 6 | 7 | # generate unique name. 8 | name=$(hostname)-$(python -c 'import random,string; print "".join([random.choice(string.letters) for i in range(10)])') 9 | exec java $vmopts -jar /var/lib/jenkins/jenkins-swarm-client.jar -name $name 10 | -------------------------------------------------------------------------------- /client/src/spotbugs/excludesFilter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # Automates creation of Release Drafts using Release Drafter 2 | # More Info: https://github.com/jenkinsci/.github/blob/master/.github/release-drafter.adoc 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # Drafts your next Release notes as Pull Requests are merged into "master" 14 | - uses: release-drafter/release-drafter@v6 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/RetryException.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | /** 4 | * Indicates a graceful error reporting that doesn't need the stack dump. 5 | * 6 | * @author Kohsuke Kawaguchi 7 | */ 8 | public class RetryException extends Exception { 9 | 10 | private static final long serialVersionUID = -9058647821506211062L; 11 | 12 | public RetryException(String message) { 13 | super(message); 14 | } 15 | 16 | public RetryException(String message, Throwable cause) { 17 | super(message, cause); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/RetryBackOffStrategyOptionHandler.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import org.kohsuke.args4j.CmdLineParser; 4 | import org.kohsuke.args4j.OptionDef; 5 | import org.kohsuke.args4j.spi.EnumOptionHandler; 6 | import org.kohsuke.args4j.spi.Setter; 7 | 8 | public class RetryBackOffStrategyOptionHandler extends EnumOptionHandler { 9 | 10 | public RetryBackOffStrategyOptionHandler( 11 | CmdLineParser parser, OptionDef option, Setter setter) { 12 | super(parser, option, setter, RetryBackOffStrategy.class); 13 | } 14 | 15 | @Override 16 | public String getDefaultMetaVariable() { 17 | return "RETRY_BACK_OFF_STRATEGY"; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/proxy.adoc: -------------------------------------------------------------------------------- 1 | = Proxy Configuration 2 | 3 | Swarm uses https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/net/ProxySelector.html#getDefault()[the default `ProxySelector`], which supports customization of the HTTP client through system properties. 4 | Use the following system properties to configure a proxy: 5 | 6 | * `http.proxyHost` 7 | * `http.proxyPort` 8 | * `https.proxyHost` 9 | * `https.proxyPort` 10 | * `http.nonProxyHosts` 11 | 12 | For example: 13 | 14 | [source,bash] 15 | ---- 16 | $ java \ 17 | -Dhttp.proxyHost=127.0.0.1 \ 18 | -Dhttp.proxyPort=3128 \ 19 | -jar swarm-client.jar 20 | ---- 21 | 22 | For more information about these properties, see https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/net/doc-files/net-properties.html[the Java documentation]. 23 | -------------------------------------------------------------------------------- /.github/workflows/jenkins-security-scan.yml: -------------------------------------------------------------------------------- 1 | # Jenkins Security Scan 2 | # For more information, see: https://www.jenkins.io/doc/developer/security/scan/ 3 | 4 | name: Jenkins Security Scan 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | types: [opened, synchronize, reopened] 12 | workflow_dispatch: 13 | 14 | permissions: 15 | security-events: write 16 | contents: read 17 | actions: read 18 | 19 | jobs: 20 | security-scan: 21 | uses: jenkins-infra/jenkins-security-scan/.github/workflows/jenkins-security-scan.yaml@v2 22 | with: 23 | java-cache: 'maven' # Optionally enable use of a build dependency cache. Specify 'maven' or 'gradle' as appropriate. 24 | # java-version: 21 # Optionally specify what version of Java to set up for the build, or remove to use a recent default. 25 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/RetryBackOffStrategy.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | public enum RetryBackOffStrategy { 4 | NONE { 5 | @Override 6 | public int waitForRetry(int retry, int interval, int maxTime) { 7 | return Math.min(maxTime, interval); 8 | } 9 | }, 10 | 11 | LINEAR { 12 | @Override 13 | public int waitForRetry(int retry, int interval, int maxTime) { 14 | return Math.min(maxTime, interval * (retry + 1)); 15 | } 16 | }, 17 | 18 | EXPONENTIAL { 19 | @Override 20 | public int waitForRetry(int retry, int interval, int maxTime) { 21 | return Math.min(maxTime, interval * (int) Math.pow(2, retry)); 22 | } 23 | }; 24 | 25 | abstract int waitForRetry(int retry, int interval, int maxTime); 26 | } 27 | -------------------------------------------------------------------------------- /plugin/src/main/java/hudson/plugins/swarm/KeepSwarmClientNodeProperty.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import edu.umd.cs.findbugs.annotations.NonNull; 4 | import hudson.Extension; 5 | import hudson.model.Node; 6 | import hudson.slaves.NodeProperty; 7 | import hudson.slaves.NodePropertyDescriptor; 8 | import org.jenkinsci.Symbol; 9 | import org.kohsuke.stapler.DataBoundConstructor; 10 | 11 | /** 12 | * {@link NodeProperty} that sets additional environment variables. 13 | * 14 | * @since 1.286 15 | */ 16 | public class KeepSwarmClientNodeProperty extends NodeProperty { 17 | 18 | @DataBoundConstructor 19 | public KeepSwarmClientNodeProperty() {} 20 | 21 | @Extension 22 | @Symbol("keepSwarmClient") 23 | public static class DescriptorImpl extends NodePropertyDescriptor { 24 | 25 | @NonNull 26 | @Override 27 | public String getDisplayName() { 28 | return "Keep Swarm client node after agent disconnect"; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/RestrictiveEntityResolver.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import org.xml.sax.EntityResolver; 4 | import org.xml.sax.InputSource; 5 | import org.xml.sax.SAXException; 6 | 7 | /** 8 | * An EntityResolver that will fail to resolve any entities. Useful in preventing External XML 9 | * Entity injection attacks. 10 | */ 11 | public final class RestrictiveEntityResolver implements EntityResolver { 12 | 13 | public static final RestrictiveEntityResolver INSTANCE = new RestrictiveEntityResolver(); 14 | 15 | private RestrictiveEntityResolver() { 16 | // prevent multiple instantiation. 17 | super(); 18 | } 19 | 20 | /** Throws a SAXException if this tried to resolve any entity. */ 21 | @Override 22 | public InputSource resolveEntity(String publicId, String systemId) throws SAXException { 23 | throw new SAXException( 24 | "Refusing to resolve entity with publicId(" + publicId + ") and systemId (" + systemId + ")"); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /docs/prometheus.adoc: -------------------------------------------------------------------------------- 1 | = Prometheus Monitoring 2 | 3 | == Getting started 4 | 5 | The Swarm client has support for https://prometheus.io[Prometheus] monitoring, which can be used to scrape data from a Prometheus server. 6 | To start a Prometheus endpoint, use a non-zero value for the `-prometheusPort` option when starting the client JAR. 7 | The service will be stopped when the Swarm client exits. 8 | 9 | The actual metrics can be accessed via the `/prometheus` endpoint. 10 | For example, if the node's IP address is `169.254.10.12`, and `9100` is passed to `-prometheusPort`, then the metrics can be accessed at `http://169.254.10.12:9100/prometheus`. 11 | 12 | == Data reported 13 | 14 | The client reports metrics for: 15 | 16 | * Basic process info, including: 17 | ** Process uptime 18 | ** CPU time consumed 19 | ** Virtual memory consumed 20 | ** Resident memory consumed 21 | ** File descriptors consumed 22 | * JVM metrics, such as: 23 | ** CPU usage 24 | ** Memory usage 25 | ** Thread states 26 | ** Garbage collection statistics 27 | ** Class loader statistics 28 | -------------------------------------------------------------------------------- /docs/configfile.adoc: -------------------------------------------------------------------------------- 1 | = YAML Configuration File 2 | 3 | Options can be configured by a YAML file using `-config`: 4 | 5 | [source,bash] 6 | ---- 7 | $ java -jar swarm-client.jar -config config.yml 8 | ---- 9 | 10 | IMPORTANT: `-config` can not be used with other options. 11 | 12 | The YAML configuration uses lower camel case naming of the xref:../README.adoc[CLI] options; aliases aren't supported. 13 | 14 | .Required: 15 | 16 | - `url` 17 | 18 | .Forbidden: 19 | 20 | - `help` 21 | - `config` 22 | - `password` (use `passwordFile` or `passwordEnvVariable` instead) 23 | 24 | .Deviant naming: 25 | 26 | - `environmentVariables` _(List)_ 27 | - `toolLocations` _(List)_ 28 | 29 | 30 | .Example Configuration: 31 | [source,yaml] 32 | ---- 33 | url: https://localhost:8080/jenkins 34 | name: agent-name-0 35 | description: Configured from yml 36 | executors: 3 37 | labels: 38 | - label-a 39 | - label-b 40 | - label-c 41 | webSocket: true 42 | environmentVariables: 43 | ENV_1: 1234 44 | ENV_2: swarm-client 45 | username: swarm 46 | passwordEnvVariable: SWARM_KEY 47 | ---- 48 | -------------------------------------------------------------------------------- /plugin/src/main/java/hudson/plugins/swarm/DownloadClientAction.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import hudson.Extension; 4 | import hudson.Plugin; 5 | import hudson.model.UnprotectedRootAction; 6 | import jakarta.servlet.ServletException; 7 | import java.io.IOException; 8 | import jenkins.model.Jenkins; 9 | import org.kohsuke.accmod.Restricted; 10 | import org.kohsuke.accmod.restrictions.NoExternalUse; 11 | import org.kohsuke.stapler.StaplerRequest2; 12 | import org.kohsuke.stapler.StaplerResponse2; 13 | 14 | @Extension 15 | public class DownloadClientAction implements UnprotectedRootAction { 16 | 17 | @Override 18 | public String getIconFileName() { 19 | return null; 20 | } 21 | 22 | @Override 23 | public String getDisplayName() { 24 | return null; 25 | } 26 | 27 | @Override 28 | public String getUrlName() { 29 | return "swarm"; 30 | } 31 | 32 | // serve static resources 33 | @Restricted(NoExternalUse.class) 34 | @SuppressWarnings({"lgtm[jenkins/csrf]", "lgtm[jenkins/no-permission-check]"}) 35 | public void doDynamic(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { 36 | Plugin plugin = Jenkins.get().getPlugin("swarm"); 37 | if (plugin != null) { 38 | plugin.doDynamic(req, rsp); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/ModeOptionHandler.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import java.util.List; 4 | import org.kohsuke.args4j.CmdLineException; 5 | import org.kohsuke.args4j.CmdLineParser; 6 | import org.kohsuke.args4j.OptionDef; 7 | import org.kohsuke.args4j.spi.OneArgumentOptionHandler; 8 | import org.kohsuke.args4j.spi.Setter; 9 | 10 | /** 11 | * Parses possible node modes: can be either 'normal' or 'exclusive'. 12 | * 13 | * @author Timur Strekalov 14 | */ 15 | public class ModeOptionHandler extends OneArgumentOptionHandler { 16 | 17 | public static final String NORMAL = "normal"; 18 | public static final String EXCLUSIVE = "exclusive"; 19 | 20 | private static final List ACCEPTABLE_VALUES = List.of(NORMAL, EXCLUSIVE); 21 | 22 | public ModeOptionHandler(CmdLineParser parser, OptionDef option, Setter setter) { 23 | super(parser, option, setter); 24 | } 25 | 26 | @Override 27 | public String parse(String argument) throws NumberFormatException, CmdLineException { 28 | if (!accepts(argument)) { 29 | throw new CmdLineException(owner, "Invalid mode", null); 30 | } 31 | 32 | return argument; 33 | } 34 | 35 | @Override 36 | public String getDefaultMetaVariable() { 37 | return "MODE"; 38 | } 39 | 40 | static boolean accepts(String value) { 41 | return ACCEPTABLE_VALUES.contains(value); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /plugin/src/main/resources/hudson/plugins/swarm/SwarmSlave/configure-entries_ja.properties: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright (c) 2004-2010, Sun Microsystems, Inc.Seiji Sogabe 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | Description=\u8aac\u660e 24 | \#\ of\ executors=\u540c\u6642\u30d3\u30eb\u30c9\u6570 25 | Remote\ FS\ root=\u30ea\u30e2\u30fc\u30c8FS\u30eb\u30fc\u30c8 26 | Labels=\u30e9\u30d9\u30eb 27 | Node\ Properties=\u30ce\u30fc\u30c9\u5c5e\u6027 28 | -------------------------------------------------------------------------------- /client/src/test/java/hudson/plugins/swarm/RetryBackOffStrategyTest.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import org.junit.Test; 6 | 7 | public class RetryBackOffStrategyTest { 8 | 9 | @Test 10 | public void should_return_same_interval_for_no_backoff() { 11 | RetryBackOffStrategy none = RetryBackOffStrategy.NONE; 12 | assertEquals(10, none.waitForRetry(0, 10, 30)); 13 | assertEquals(10, none.waitForRetry(1, 10, 30)); 14 | assertEquals(10, none.waitForRetry(2, 10, 30)); 15 | assertEquals(10, none.waitForRetry(3, 10, 30)); 16 | } 17 | 18 | @Test 19 | public void should_increase_interval_for_linear_backoff() { 20 | RetryBackOffStrategy linear = RetryBackOffStrategy.LINEAR; 21 | assertEquals(10, linear.waitForRetry(0, 10, 30)); 22 | assertEquals(20, linear.waitForRetry(1, 10, 30)); 23 | assertEquals(30, linear.waitForRetry(2, 10, 30)); 24 | assertEquals(30, linear.waitForRetry(3, 10, 30)); 25 | } 26 | 27 | @Test 28 | public void should_double_interval_up_to_max_for_exponential_backoff() { 29 | RetryBackOffStrategy exponential = RetryBackOffStrategy.EXPONENTIAL; 30 | assertEquals(10, exponential.waitForRetry(0, 10, 100)); 31 | assertEquals(20, exponential.waitForRetry(1, 10, 100)); 32 | assertEquals(40, exponential.waitForRetry(2, 10, 100)); 33 | assertEquals(80, exponential.waitForRetry(3, 10, 100)); 34 | assertEquals(100, exponential.waitForRetry(4, 10, 100)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/logging.properties: -------------------------------------------------------------------------------- 1 | ############################################################ 2 | # Sample Logging Configuration File 3 | # 4 | # You can use a different file by specifying a filename 5 | # with the java.util.logging.config.file system property. 6 | # For example java -Djava.util.logging.config.file=myfile 7 | ############################################################ 8 | 9 | ############################################################ 10 | # Global properties 11 | ############################################################ 12 | 13 | # "handlers" specifies a comma separated list of log Handler 14 | # classes. These handlers will be installed during VM startup. 15 | # Note that these classes must be on the system classpath. 16 | handlers = java.util.logging.FileHandler, java.util.logging.ConsoleHandler 17 | 18 | # Default global logging level. 19 | # This specifies which kinds of events are logged across 20 | # all loggers. For any given handler or facility this global 21 | # level can be overriden. 22 | .level = ALL 23 | 24 | ############################################################ 25 | # Handler specific properties 26 | ############################################################ 27 | 28 | java.util.logging.SimpleFormatter.format = %1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS %4$s %2$s %5$s%6$s%n 29 | 30 | java.util.logging.FileHandler.pattern = %h/swarm-client%u.log 31 | java.util.logging.FileHandler.limit = 20000000 32 | java.util.logging.FileHandler.count = 5 33 | java.util.logging.FileHandler.formatter = java.util.logging.SimpleFormatter 34 | 35 | java.util.logging.ConsoleHandler.level = CONFIG 36 | java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter 37 | -------------------------------------------------------------------------------- /docs/logging.adoc: -------------------------------------------------------------------------------- 1 | = Logging and Diagnostics 2 | 3 | == Standard logging 4 | 5 | https://www.jenkins.io/doc/book/system-administration/viewing-logs/[Jenkins uses `java.util.logging` for logging], including in https://github.com/jenkinsci/remoting[Remoting] and https://github.com/jenkinsci/swarm-plugin[Swarm]. 6 | By default, `java.util.logging` uses the configuration from `${JAVA_HOME}/jre/lib/logging.properties`. 7 | This is typically something like the following: 8 | 9 | [source,properties] 10 | ---- 11 | handlers = java.util.logging.ConsoleHandler 12 | .level = INFO 13 | java.util.logging.ConsoleHandler.level = INFO 14 | java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter 15 | ---- 16 | 17 | The default handler, https://docs.oracle.com/en/java/javase/11/docs/api/java.logging/java/util/logging/ConsoleHandler.html[`ConsoleHandler`], sends `INFO`-level log records to `System.err`, using https://docs.oracle.com/en/java/javase/11/docs/api/java.logging/java/util/logging/SimpleFormatter.html[`SimpleFormatter`]. 18 | 19 | == Configuration 20 | 21 | To get more detailed logs from the Swarm client, create a custom `logging.properties` file and pass it in via the `java.util.logging.config.file` property. 22 | This repository contains link:../client/logging.properties[a sample verbose `logging.properties` file]. 23 | For example: 24 | 25 | [source,bash] 26 | ---- 27 | $ java -Djava.util.logging.config.file='logging.properties' -jar swarm-client.jar 28 | ---- 29 | 30 | For more information about the property file format, see the https://docs.oracle.com/cd/E19717-01/819-7753/6n9m71435/index.html[Oracle documentation] and http://tutorials.jenkov.com/java-logging/configuration.html[this guide]. 31 | -------------------------------------------------------------------------------- /plugin/src/main/resources/hudson/plugins/swarm/SwarmSlave/configure-entries.jelly: -------------------------------------------------------------------------------- 1 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | org.jenkins-ci.plugins 6 | plugin 7 | 5.26 8 | 9 | 10 | 11 | swarm-plugin 12 | ${revision}${changelist} 13 | pom 14 | Swarm Plugin Parent POM 15 | The Swarm plugin enables nodes to join a nearby Jenkins controller, thereby forming an ad-hoc cluster. 16 | https://github.com/jenkinsci/swarm-plugin 17 | 18 | 19 | 20 | MIT License 21 | https://opensource.org/licenses/MIT 22 | 23 | 24 | 25 | 26 | 27 | basil 28 | Basil Crow 29 | 30 | 31 | 32 | 33 | client 34 | plugin 35 | 36 | 37 | 38 | scm:git:https://github.com/${gitHubRepo}.git 39 | scm:git:git@github.com:${gitHubRepo}.git 40 | ${scmTag} 41 | https://github.com/${gitHubRepo} 42 | 43 | 44 | 45 | 3.52 46 | -SNAPSHOT 47 | 48 | 2.479 49 | ${jenkins.baseline}.3 50 | jenkinsci/swarm-plugin 51 | false 52 | 53 | 54 | 55 | 56 | repo.jenkins-ci.org 57 | https://repo.jenkins-ci.org/public/ 58 | 59 | 60 | 61 | 62 | 63 | repo.jenkins-ci.org 64 | https://repo.jenkins-ci.org/public/ 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /plugin/src/main/java/hudson/plugins/swarm/SwarmLauncher.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import edu.umd.cs.findbugs.annotations.NonNull; 4 | import hudson.Extension; 5 | import hudson.Functions; 6 | import hudson.model.Descriptor; 7 | import hudson.model.Slave; 8 | import hudson.model.TaskListener; 9 | import hudson.slaves.ComputerLauncher; 10 | import hudson.slaves.JNLPLauncher; 11 | import hudson.slaves.SlaveComputer; 12 | import java.io.IOException; 13 | import java.util.logging.Level; 14 | import java.util.logging.Logger; 15 | import jenkins.model.Jenkins; 16 | import jenkins.slaves.DefaultJnlpSlaveReceiver; 17 | import org.jenkinsci.remoting.engine.JnlpConnectionState; 18 | 19 | /** 20 | * {@link ComputerLauncher} for Swarm agents. We extend {@link JNLPLauncher} for compatibility with 21 | * {@link DefaultJnlpSlaveReceiver#afterProperties(JnlpConnectionState)}. 22 | */ 23 | public class SwarmLauncher extends JNLPLauncher { 24 | 25 | private static final Logger LOGGER = Logger.getLogger(SwarmLauncher.class.getName()); 26 | 27 | public SwarmLauncher() { 28 | super(false); 29 | } 30 | 31 | @Override 32 | public void afterDisconnect(SlaveComputer computer, TaskListener listener) { 33 | super.afterDisconnect(computer, listener); 34 | 35 | Slave node = computer.getNode(); 36 | if (node != null) { 37 | String nodeName = node.getNodeName(); 38 | try { 39 | // Don't remove the node object if we've disconnected, if the node doesn't want to 40 | // be removed 41 | KeepSwarmClientNodeProperty keepClientProp = node.getNodeProperty(KeepSwarmClientNodeProperty.class); 42 | 43 | // We use the existance of the node property on the node itself as a boolean check 44 | if (keepClientProp == null) { 45 | LOGGER.log(Level.INFO, "Removing Swarm Node for computer [{0}]", nodeName); 46 | Jenkins.get().removeNode(node); 47 | } else { 48 | listener.getLogger().printf("Skipping removal of Node for computer [%1$s]", nodeName); 49 | LOGGER.log(Level.INFO, "Skipping removal of Node for computer [{0}]", nodeName); 50 | } 51 | } catch (IOException e) { 52 | Functions.printStackTrace(e, listener.error("Failed to remove node [%1$s]", nodeName)); 53 | LOGGER.log( 54 | Level.WARNING, 55 | String.format( 56 | "Failed to remove node [%1$s] %n%2$s", 57 | nodeName, Functions.printThrowable(e).trim())); 58 | } 59 | } else { 60 | listener.getLogger().printf("Node for computer [%1$s] appears to have been removed already%n", computer); 61 | } 62 | } 63 | 64 | @Extension 65 | public static class DescriptorImpl extends Descriptor { 66 | 67 | @NonNull 68 | @Override 69 | public String getDisplayName() { 70 | return "Launch Swarm agent"; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /plugin/src/test/java/hudson/plugins/swarm/PipelineJobRestartTest.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import static org.junit.Assert.assertNotNull; 4 | 5 | import hudson.model.Node; 6 | import hudson.plugins.swarm.test.SwarmClientRule; 7 | import java.util.concurrent.TimeUnit; 8 | import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; 9 | import org.jenkinsci.plugins.workflow.job.WorkflowJob; 10 | import org.jenkinsci.plugins.workflow.job.WorkflowRun; 11 | import org.jenkinsci.plugins.workflow.support.steps.ExecutorStepExecution; 12 | import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep; 13 | import org.junit.ClassRule; 14 | import org.junit.Rule; 15 | import org.junit.Test; 16 | import org.junit.rules.TemporaryFolder; 17 | import org.jvnet.hudson.test.BuildWatcher; 18 | import org.jvnet.hudson.test.JenkinsRule; 19 | import org.jvnet.hudson.test.JenkinsSessionRule; 20 | 21 | public class PipelineJobRestartTest { 22 | 23 | @ClassRule 24 | public static BuildWatcher buildWatcher = new BuildWatcher(); 25 | 26 | @Rule(order = 10) 27 | public JenkinsSessionRule sessions = new JenkinsSessionRule(); 28 | 29 | /** For use with {@link #swarmClientRule} */ 30 | private JenkinsRule holder; 31 | 32 | @Rule(order = 20) 33 | public TemporaryFolder temporaryFolder = 34 | TemporaryFolder.builder().assureDeletion().build(); 35 | 36 | @Rule(order = 30) 37 | public SwarmClientRule swarmClientRule = new SwarmClientRule(() -> holder, temporaryFolder); 38 | 39 | /** 40 | * Starts a Jenkins job on a Swarm agent, restarts Jenkins while the job is running, and 41 | * verifies that the job continues running on the same agent after Jenkins has been restarted. 42 | */ 43 | @Test 44 | public void buildShellScriptAfterRestart() throws Throwable { 45 | // Extend the timeout to make flaky tests less flaky. 46 | ExecutorStepExecution.TIMEOUT_WAITING_FOR_NODE_MILLIS = TimeUnit.MINUTES.toMillis(1); 47 | 48 | sessions.then(r -> { 49 | holder = r; 50 | swarmClientRule.globalSecurityConfigurationBuilder().build(); 51 | 52 | // "-deleteExistingClients" is needed so that the Swarm Client can connect 53 | // after the restart. 54 | Node node = swarmClientRule.createSwarmClient("-deleteExistingClients"); 55 | 56 | WorkflowJob project = r.createProject(WorkflowJob.class, "test"); 57 | project.setConcurrentBuild(false); 58 | project.setDefinition(new CpsFlowDefinition(PipelineJobTest.getFlow(node, 1), true)); 59 | 60 | WorkflowRun build = project.scheduleBuild2(0).waitForStart(); 61 | SemaphoreStep.waitForStart("wait-0/1", build); 62 | }); 63 | sessions.then(r -> { 64 | holder = r; 65 | SemaphoreStep.success("wait-0/1", null); 66 | WorkflowJob project = r.jenkins.getItemByFullName("test", WorkflowJob.class); 67 | assertNotNull(project); 68 | WorkflowRun build = project.getBuildByNumber(1); 69 | r.assertBuildStatusSuccess(r.waitForCompletion(build)); 70 | r.assertLogContains("ON_SWARM_CLIENT=true", build); 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /plugin/src/main/java/hudson/plugins/swarm/SwarmSlave.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import edu.umd.cs.findbugs.annotations.NonNull; 4 | import hudson.Extension; 5 | import hudson.Util; 6 | import hudson.model.Descriptor.FormException; 7 | import hudson.model.Node; 8 | import hudson.model.Slave; 9 | import hudson.slaves.ComputerLauncher; 10 | import hudson.slaves.EphemeralNode; 11 | import hudson.slaves.NodeProperty; 12 | import hudson.slaves.RetentionStrategy; 13 | import java.io.IOException; 14 | import java.util.List; 15 | import org.kohsuke.stapler.DataBoundConstructor; 16 | 17 | /** 18 | * {@link Slave} created by ad-hoc local systems. 19 | * 20 | *

This acts like an inbound agent, except when the client disconnects, the agent will be 21 | * deleted. 22 | * 23 | * @author Kohsuke Kawaguchi 24 | */ 25 | public class SwarmSlave extends Slave implements EphemeralNode { 26 | 27 | private static final long serialVersionUID = -1527777529814020243L; 28 | 29 | public SwarmSlave( 30 | String name, 31 | String nodeDescription, 32 | String remoteFS, 33 | String numExecutors, 34 | Mode mode, 35 | String label, 36 | List> nodeProperties) 37 | throws IOException, FormException { 38 | this( 39 | name, 40 | nodeDescription, 41 | remoteFS, 42 | numExecutors, 43 | mode, 44 | label, 45 | SELF_CLEANUP_LAUNCHER, 46 | RetentionStrategy.NOOP, 47 | nodeProperties); 48 | } 49 | 50 | @DataBoundConstructor 51 | public SwarmSlave( 52 | String name, 53 | String nodeDescription, 54 | String remoteFS, 55 | String numExecutors, 56 | Mode mode, 57 | String labelString, 58 | ComputerLauncher launcher, 59 | RetentionStrategy retentionStrategy, 60 | List> nodeProperties) 61 | throws FormException, IOException { 62 | super(name, remoteFS, launcher); 63 | setNodeDescription(nodeDescription); 64 | setMode(mode); 65 | setLabelString(labelString); 66 | setRetentionStrategy(retentionStrategy); 67 | setNodeProperties(nodeProperties); 68 | 69 | final Number executors = Util.tryParseNumber(numExecutors, 1); 70 | setNumExecutors(executors != null ? executors.intValue() : 1); 71 | } 72 | 73 | @Override 74 | public Node asNode() { 75 | return this; 76 | } 77 | 78 | @Extension 79 | public static final class DescriptorImpl extends SlaveDescriptor { 80 | 81 | @Override 82 | @NonNull 83 | public String getDisplayName() { 84 | return "Swarm agent"; 85 | } 86 | 87 | /** We only create this kind of nodes programmatically. */ 88 | @Override 89 | public boolean isInstantiable() { 90 | return false; 91 | } 92 | } 93 | 94 | /** {@link ComputerLauncher} that destroys itself upon a connection termination. */ 95 | private static final ComputerLauncher SELF_CLEANUP_LAUNCHER = new SwarmLauncher(); 96 | } 97 | -------------------------------------------------------------------------------- /client/smf.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/XmlUtils.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import edu.umd.cs.findbugs.annotations.NonNull; 4 | import java.io.IOException; 5 | import java.io.InputStream; 6 | import java.util.logging.Level; 7 | import java.util.logging.LogManager; 8 | import java.util.logging.Logger; 9 | import javax.xml.XMLConstants; 10 | import javax.xml.parsers.DocumentBuilder; 11 | import javax.xml.parsers.DocumentBuilderFactory; 12 | import javax.xml.parsers.ParserConfigurationException; 13 | import org.w3c.dom.Document; 14 | import org.xml.sax.SAXException; 15 | 16 | public final class XmlUtils { 17 | 18 | private static final Logger logger = LogManager.getLogManager().getLogger(XmlUtils.class.getName()); 19 | 20 | /** 21 | * Parse the supplied XML stream data to a {@link Document}. 22 | * 23 | *

This function does not close the stream. 24 | * 25 | * @param stream The XML stream. 26 | * @return The XML {@link Document}. 27 | * @throws SAXException Error parsing the XML stream data e.g. badly formed XML. 28 | * @throws IOException Error reading from the steam. 29 | */ 30 | public static @NonNull Document parse(@NonNull InputStream stream) throws IOException, SAXException { 31 | DocumentBuilder docBuilder; 32 | 33 | try { 34 | docBuilder = newDocumentBuilderFactory().newDocumentBuilder(); 35 | docBuilder.setEntityResolver(RestrictiveEntityResolver.INSTANCE); 36 | } catch (ParserConfigurationException e) { 37 | throw new IllegalStateException("Unexpected error creating DocumentBuilder.", e); 38 | } 39 | 40 | return docBuilder.parse(stream); 41 | } 42 | 43 | private static DocumentBuilderFactory newDocumentBuilderFactory() { 44 | DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); 45 | // Set parser features to prevent against XXE etc. 46 | // Note: setting only the external entity features on DocumentBuilderFactory instance 47 | // (ala how safeTransform does it for SAXTransformerFactory) does seem to work (was still 48 | // processing the entities - tried Oracle JDK 7 and 8 on OSX). Setting seems a bit extreme, 49 | // but looks like there's no other choice. 50 | documentBuilderFactory.setXIncludeAware(false); 51 | documentBuilderFactory.setExpandEntityReferences(false); 52 | setDocumentBuilderFactoryFeature(documentBuilderFactory, XMLConstants.FEATURE_SECURE_PROCESSING, true); 53 | setDocumentBuilderFactoryFeature( 54 | documentBuilderFactory, "http://xml.org/sax/features/external-general-entities", false); 55 | setDocumentBuilderFactoryFeature( 56 | documentBuilderFactory, "http://xml.org/sax/features/external-parameter-entities", false); 57 | setDocumentBuilderFactoryFeature( 58 | documentBuilderFactory, "http://apache.org/xml/features/disallow-doctype-decl", true); 59 | 60 | return documentBuilderFactory; 61 | } 62 | 63 | private static void setDocumentBuilderFactoryFeature( 64 | DocumentBuilderFactory documentBuilderFactory, String feature, boolean state) { 65 | try { 66 | documentBuilderFactory.setFeature(feature, state); 67 | } catch (Exception e) { 68 | logger.log( 69 | Level.WARNING, 70 | String.format("Failed to set the XML Document Builder factory feature %s to %s", feature, state), 71 | e); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /client/src/test/java/hudson/plugins/swarm/ClientTest.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import static org.hamcrest.CoreMatchers.containsString; 4 | import static org.hamcrest.MatcherAssert.assertThat; 5 | import static org.junit.Assert.assertThrows; 6 | 7 | import java.net.URL; 8 | import org.junit.Test; 9 | 10 | public class ClientTest { 11 | 12 | @Test 13 | public void should_not_retry_more_than_specified() { 14 | Options options = givenBackOff(RetryBackOffStrategy.NONE); 15 | // one try 16 | options.retry = 1; 17 | runAndVerify(options, "Exited with status 1 after 0 seconds"); 18 | // a few tries 19 | options.retry = 5; 20 | runAndVerify(options, "Exited with status 1 after 40 seconds"); 21 | } 22 | 23 | @Test 24 | public void should_run_with_web_socket() { 25 | Options options = givenBackOff(RetryBackOffStrategy.NONE); 26 | options.webSocket = true; 27 | options.retry = -1; 28 | runAndVerify(options, "Running long enough"); 29 | } 30 | 31 | @Test 32 | public void should_keep_retrying_if_there_is_no_limit() { 33 | Options options = givenBackOff(RetryBackOffStrategy.NONE); 34 | options.retry = -1; 35 | runAndVerify(options, "Running long enough"); 36 | } 37 | 38 | @Test 39 | public void should_run_with_no_backoff() { 40 | Options options = givenBackOff(RetryBackOffStrategy.NONE); 41 | runAndVerify(options, "Exited with status 1 after 90 seconds"); 42 | } 43 | 44 | @Test 45 | public void should_run_with_linear_backoff() { 46 | Options options = givenBackOff(RetryBackOffStrategy.LINEAR); 47 | runAndVerify(options, "Exited with status 1 after 450 seconds"); 48 | } 49 | 50 | @Test 51 | public void should_run_with_exponential_backoff() { 52 | Options options = givenBackOff(RetryBackOffStrategy.EXPONENTIAL); 53 | runAndVerify(options, "Exited with status 1 after 750 seconds"); 54 | } 55 | 56 | private Options givenBackOff(RetryBackOffStrategy retryBackOffStrategy) { 57 | Options options = new Options(); 58 | options.url = "http://localhost:8080"; 59 | options.retryBackOffStrategy = retryBackOffStrategy; 60 | options.retry = 10; 61 | options.retryInterval = 10; 62 | options.maxRetryInterval = 120; 63 | return options; 64 | } 65 | 66 | private void runAndVerify(Options options, String expectedResult) { 67 | SwarmClient swarmClient = new DummySwarmClient(options); 68 | IllegalStateException thrown = 69 | assertThrows(IllegalStateException.class, () -> Client.run(swarmClient, options)); 70 | assertThat(thrown.getMessage(), containsString(expectedResult)); 71 | } 72 | 73 | private static class DummySwarmClient extends SwarmClient { 74 | 75 | private int totalWaitTime; 76 | 77 | DummySwarmClient(Options options) { 78 | super(options); 79 | } 80 | 81 | @Override 82 | protected void createSwarmAgent(URL url) throws RetryException { 83 | throw new RetryException("try again"); 84 | } 85 | 86 | @Override 87 | public void exitWithStatus(int status) { 88 | throw new IllegalStateException("Exited with status " + status + " after " + totalWaitTime + " seconds"); 89 | } 90 | 91 | @Override 92 | public void sleepSeconds(int waitTime) { 93 | totalWaitTime += waitTime; 94 | if (totalWaitTime > 1000) { 95 | throw new IllegalStateException("Running long enough"); 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /plugin/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | org.jenkins-ci.plugins 6 | swarm-plugin 7 | ${revision}${changelist} 8 | 9 | 10 | swarm 11 | hpi 12 | Swarm Plugin 13 | https://github.com/jenkinsci/swarm-plugin 14 | 15 | 16 | true 17 | 18 | 19 | 20 | 21 | 22 | io.jenkins.tools.bom 23 | bom-${jenkins.baseline}.x 24 | 5054.v620b_5d2b_d5e6 25 | pom 26 | import 27 | 28 | 29 | 30 | 31 | 32 | 33 | org.jenkins-ci.plugins 34 | matrix-auth 35 | test 36 | 37 | 38 | org.jenkins-ci.plugins 39 | role-strategy 40 | test 41 | 42 | 43 | org.jenkins-ci.plugins.workflow 44 | workflow-basic-steps 45 | test 46 | 47 | 48 | org.jenkins-ci.plugins.workflow 49 | workflow-cps 50 | test 51 | 52 | 53 | org.jenkins-ci.plugins.workflow 54 | workflow-durable-task-step 55 | test 56 | 57 | 58 | org.jenkins-ci.plugins.workflow 59 | workflow-job 60 | test 61 | 62 | 63 | org.jenkins-ci.plugins.workflow 64 | workflow-support 65 | test 66 | 67 | 68 | org.jenkins-ci.plugins.workflow 69 | workflow-support 70 | tests 71 | test 72 | 73 | 74 | 75 | 76 | 77 | 78 | org.apache.maven.plugins 79 | maven-dependency-plugin 80 | 81 | 82 | 83 | ${project.groupId} 84 | swarm-client 85 | ${project.version} 86 | jar 87 | true 88 | ${project.build.directory}/${project.artifactId} 89 | swarm-client.jar 90 | 91 | 92 | 93 | 94 | 95 | copy 96 | 97 | copy 98 | 99 | process-resources 100 | 101 | 102 | 103 | 104 | org.jenkins-ci.tools 105 | maven-hpi-plugin 106 | 107 | ${project.build.directory}/${project.artifactId} 108 | 109 | 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/YamlConfig.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import java.io.InputStream; 4 | import java.lang.reflect.Field; 5 | import java.util.Objects; 6 | import org.kohsuke.args4j.Option; 7 | import org.yaml.snakeyaml.LoaderOptions; 8 | import org.yaml.snakeyaml.Yaml; 9 | import org.yaml.snakeyaml.constructor.Constructor; 10 | 11 | /** Reads {@link Options} from a YAML file. */ 12 | public class YamlConfig { 13 | private static final Options defaultOptions = new Options(); 14 | private final Yaml yaml; 15 | 16 | public YamlConfig() { 17 | final LoaderOptions loaderOptions = new LoaderOptions(); 18 | loaderOptions.setEnumCaseSensitive(false); 19 | this.yaml = new Yaml(new Constructor(Options.class, loaderOptions)); 20 | } 21 | 22 | public Options loadOptions(InputStream inputStream) throws ConfigurationException { 23 | final Options options = yaml.loadAs(inputStream, Options.class); 24 | checkForbidden(options.config != null, "config"); 25 | checkForbidden(options.password != null, "password"); 26 | 27 | for (Field field : Options.class.getDeclaredFields()) { 28 | checkField(options, field); 29 | } 30 | 31 | if (!ModeOptionHandler.accepts(options.mode)) { 32 | throw new ConfigurationException("'mode' has an invalid value: '" + options.mode + "'"); 33 | } 34 | 35 | return options; 36 | } 37 | 38 | private void checkForbidden(boolean hasValue, String name) throws ConfigurationException { 39 | if (hasValue) { 40 | throw new ConfigurationException("'" + name + "' is not allowed in configuration file"); 41 | } 42 | } 43 | 44 | private boolean isSet(Options options, Field field) throws NoSuchFieldException, IllegalAccessException { 45 | final Object defaultValue = 46 | Options.class.getDeclaredField(field.getName()).get(defaultOptions); 47 | return !Objects.equals(field.get(options), defaultValue); 48 | } 49 | 50 | private void checkField(Options options, Field field) throws ConfigurationException { 51 | try { 52 | final Option annotation = field.getAnnotation(Option.class); 53 | 54 | if (annotation != null) { 55 | if (annotation.required() && !isSet(options, field)) { 56 | throw new ConfigurationException("'" + field.getName() + "' is required"); 57 | } 58 | 59 | if (annotation.help()) { 60 | checkForbidden(isSet(options, field), field.getName()); 61 | } 62 | 63 | checkForbids(options, field, annotation.forbids()); 64 | checkDepends(options, field, annotation.depends()); 65 | } 66 | } catch (NoSuchFieldException | IllegalAccessException e) { 67 | throw new AssertionError(e); 68 | } 69 | } 70 | 71 | private void checkDepends(Options options, Field field, String[] depends) 72 | throws NoSuchFieldException, IllegalAccessException, ConfigurationException { 73 | for (String dependsOnOption : depends) { 74 | final Field dependsOn = fieldForOption(dependsOnOption); 75 | 76 | if (isSet(options, field) && !isSet(options, dependsOn)) { 77 | throw new ConfigurationException("'" + field.getName() + "' depends on '" + dependsOn.getName() + "'"); 78 | } 79 | } 80 | } 81 | 82 | private void checkForbids(Options options, Field field, String[] forbids) 83 | throws NoSuchFieldException, IllegalAccessException, ConfigurationException { 84 | for (String forbidden : forbids) { 85 | final Field forbiddenField = fieldForOption(forbidden); 86 | 87 | if (isSet(options, field) && isSet(options, forbiddenField)) { 88 | throw new ConfigurationException( 89 | "'" + field.getName() + "' can not be used with '" + forbiddenField.getName() + "'"); 90 | } 91 | } 92 | } 93 | 94 | private Field fieldForOption(String option) throws NoSuchFieldException { 95 | return Options.class.getDeclaredField(option.replace("-", "")); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /client/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | 5 | org.jenkins-ci.plugins 6 | swarm-plugin 7 | ${revision}${changelist} 8 | 9 | 10 | swarm-client 11 | jar 12 | Swarm Client 13 | 14 | 15 | 1.15.4 16 | 17 | 18 | 19 | 20 | 21 | org.slf4j 22 | slf4j-bom 23 | 2.0.17 24 | pom 25 | import 26 | 27 | 28 | 29 | 30 | 31 | 36 | 37 | args4j 38 | args4j 39 | 2.33 40 | 41 | 42 | com.github.spotbugs 43 | spotbugs-annotations 44 | true 45 | 46 | 47 | com.google.code.findbugs 48 | jsr305 49 | 50 | 51 | 52 | 53 | io.micrometer 54 | micrometer-core 55 | ${micrometer.version} 56 | 57 | 58 | io.micrometer 59 | micrometer-registry-prometheus 60 | ${micrometer.version} 61 | 62 | 63 | org.jenkins-ci.main 64 | remoting 65 | 3327.v868139a_d00e0 66 | 67 | 68 | org.slf4j 69 | slf4j-jdk14 70 | 71 | 72 | org.yaml 73 | snakeyaml 74 | 2.5 75 | 76 | 77 | junit 78 | junit 79 | test 80 | 81 | 82 | 83 | 84 | ${project.artifactId} 85 | 86 | 87 | org.apache.maven.plugins 88 | maven-shade-plugin 89 | 3.6.0 90 | 91 | 92 | 93 | shade 94 | 95 | package 96 | 97 | false 98 | 99 | 100 | com.github.spotbugs:spotbugs-annotations 101 | 102 | 103 | 104 | 105 | args4j:args4j 106 | 107 | META-INF/MANIFEST.MF 108 | OSGI-OPT/**/*.html 109 | OSGI-OPT/**/*.java 110 | 111 | 112 | 113 | 114 | 115 | 116 | hudson.plugins.swarm.Client 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /client/src/test/java/hudson/plugins/swarm/SwarmClientTest.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import static org.hamcrest.CoreMatchers.hasItems; 4 | import static org.hamcrest.MatcherAssert.assertThat; 5 | import static org.junit.Assert.assertEquals; 6 | import static org.junit.Assert.assertNotNull; 7 | 8 | import java.io.IOException; 9 | import java.io.Writer; 10 | import java.net.http.HttpClient; 11 | import java.nio.charset.StandardCharsets; 12 | import java.nio.file.Files; 13 | import java.nio.file.Path; 14 | import java.util.List; 15 | import org.junit.ClassRule; 16 | import org.junit.Test; 17 | import org.junit.rules.TemporaryFolder; 18 | 19 | public class SwarmClientTest { 20 | 21 | @ClassRule(order = 20) 22 | public static TemporaryFolder temporaryFolder = 23 | TemporaryFolder.builder().assureDeletion().build(); 24 | 25 | @Test 26 | public void should_create_instance_on_default_options() { 27 | Options options = new Options(); 28 | SwarmClient swc = new SwarmClient(options); 29 | assertNotNull(swc); 30 | } 31 | 32 | @Test 33 | public void should_try_to_create_http_connection_on_default_options() throws IOException { 34 | Options options = new Options(); 35 | HttpClient client = SwarmClient.createHttpClient(options); 36 | assertNotNull(client); 37 | } 38 | 39 | /* Below we have a series of tests which make sure that different ways 40 | * of passing labels (usually via labelsFile) end up with a sane set. 41 | * Customized options may be provided to test e.g. concatenation of 42 | * label sets passed explicitly and by file. 43 | * See PR https://github.com/jenkinsci/swarm-plugin/pull/420 for how 44 | * this parsing misbehaved earlier. 45 | */ 46 | private static void test_labelsFile(String labelsToAdd, String... expected) throws IOException { 47 | test_labelsFile(null, labelsToAdd, expected); 48 | } 49 | 50 | private static void test_labelsFile(Options options, String labelsToAdd, String... expected) throws IOException { 51 | Path labelsFile = Files.createTempFile(temporaryFolder.getRoot().toPath(), "labelsFile", ".txt"); 52 | 53 | try (Writer writer = Files.newBufferedWriter(labelsFile, StandardCharsets.UTF_8)) { 54 | writer.write(labelsToAdd); 55 | } 56 | 57 | if (options == null) { 58 | options = new Options(); 59 | } 60 | 61 | options.labelsFile = labelsFile.toString(); 62 | 63 | SwarmClient swc = new SwarmClient(options); 64 | assertNotNull(swc); 65 | 66 | List labels = swc.getOptionsLabels(); 67 | assertNotNull(labels); 68 | 69 | /* Exactly same amount of entries as expected */ 70 | /* import of containsInAnyOrder failed for me, so... 71 | * assertThat(labels, containsInAnyOrder(expected)); */ 72 | assertThat(labels, hasItems(expected)); 73 | assertEquals(labels.size(), expected.length); 74 | 75 | Files.delete(labelsFile); 76 | } 77 | 78 | @Test 79 | public void parse_options_separated_by_spaces() throws IOException { 80 | String labelsFileContent = "COMPILER=GCC COMPILER=CLANG ARCH64=amd64 ARCH32=i386"; 81 | test_labelsFile(labelsFileContent, "COMPILER=GCC", "COMPILER=CLANG", "ARCH64=amd64", "ARCH32=i386"); 82 | } 83 | 84 | @Test 85 | public void parse_options_surrounded_by_spaces() throws IOException { 86 | String labelsFileContent = " COMPILER=GCC COMPILER=CLANG ARCH64=amd64 ARCH32=i386 "; 87 | test_labelsFile(labelsFileContent, "COMPILER=GCC", "COMPILER=CLANG", "ARCH64=amd64", "ARCH32=i386"); 88 | } 89 | 90 | @Test 91 | public void parse_options_separated_by_tabs_and_eols() throws IOException { 92 | String labelsFileContent = " COMPILER=GCC\n COMPILER=CLANG\tARCH64=amd64 \n ARCH32=i386 \n"; 93 | test_labelsFile(labelsFileContent, "COMPILER=GCC", "COMPILER=CLANG", "ARCH64=amd64", "ARCH32=i386"); 94 | } 95 | 96 | @Test 97 | public void parse_options_multiline_indented_all() throws IOException { 98 | String labelsFileContent = " COMPILER=GCC\n COMPILER=CLANG\n ARCH64=amd64\n ARCH32=i386\n"; 99 | test_labelsFile(labelsFileContent, "COMPILER=GCC", "COMPILER=CLANG", "ARCH64=amd64", "ARCH32=i386"); 100 | } 101 | 102 | @Test 103 | public void parse_options_multiline_indented_all_but_first() throws IOException { 104 | String labelsFileContent = "COMPILER=GCC\n COMPILER=CLANG\n ARCH64=amd64\n ARCH32=i386\n"; 105 | test_labelsFile(labelsFileContent, "COMPILER=GCC", "COMPILER=CLANG", "ARCH64=amd64", "ARCH32=i386"); 106 | } 107 | 108 | @Test 109 | public void parse_options_multiline_not_indented() throws IOException { 110 | String labelsFileContent = "COMPILER=GCC\nCOMPILER=CLANG\nARCH64=amd64\nARCH32=i386\n"; 111 | test_labelsFile(labelsFileContent, "COMPILER=GCC", "COMPILER=CLANG", "ARCH64=amd64", "ARCH32=i386"); 112 | } 113 | 114 | @Test 115 | public void parse_options_multiline_trailins_whitespace() throws IOException { 116 | String labelsFileContent = "COMPILER=GCC\t\nCOMPILER=CLANG \nARCH64=amd64 \n\nARCH32=i386\n"; 117 | test_labelsFile(labelsFileContent, "COMPILER=GCC", "COMPILER=CLANG", "ARCH64=amd64", "ARCH32=i386"); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /plugin/src/test/java/hudson/plugins/swarm/PipelineJobTest.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import static org.junit.Assert.assertNotNull; 4 | import static org.junit.Assert.assertTrue; 5 | 6 | import hudson.Functions; 7 | import hudson.model.Computer; 8 | import hudson.model.Node; 9 | import hudson.plugins.swarm.test.SwarmClientRule; 10 | import java.io.File; 11 | import java.io.FileOutputStream; 12 | import java.io.IOException; 13 | import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; 14 | import org.jenkinsci.plugins.workflow.job.WorkflowJob; 15 | import org.jenkinsci.plugins.workflow.job.WorkflowRun; 16 | import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep; 17 | import org.junit.Assume; 18 | import org.junit.Before; 19 | import org.junit.ClassRule; 20 | import org.junit.Rule; 21 | import org.junit.Test; 22 | import org.junit.rules.TemporaryFolder; 23 | import org.jvnet.hudson.test.BuildWatcher; 24 | import org.jvnet.hudson.test.JenkinsRule; 25 | 26 | public class PipelineJobTest { 27 | 28 | @ClassRule 29 | public static BuildWatcher buildWatcher = new BuildWatcher(); 30 | 31 | @Rule(order = 10) 32 | public JenkinsRule j = new JenkinsRule(); 33 | 34 | @Rule(order = 20) 35 | public TemporaryFolder temporaryFolder = 36 | TemporaryFolder.builder().assureDeletion().build(); 37 | 38 | @Rule(order = 30) 39 | public SwarmClientRule swarmClientRule = new SwarmClientRule(() -> j, temporaryFolder); 40 | 41 | @Before 42 | public void configureGlobalSecurity() throws IOException { 43 | swarmClientRule.globalSecurityConfigurationBuilder().build(); 44 | } 45 | 46 | /** Executes a shell script build on a Swarm agent. */ 47 | @Test 48 | public void buildShellScript() throws Exception { 49 | Node node = swarmClientRule.createSwarmClient(); 50 | 51 | WorkflowJob project = j.createProject(WorkflowJob.class); 52 | project.setConcurrentBuild(false); 53 | project.setDefinition(new CpsFlowDefinition(getFlow(node, 0), true)); 54 | 55 | WorkflowRun build = j.buildAndAssertSuccess(project); 56 | j.assertLogContains("ON_SWARM_CLIENT=true", build); 57 | } 58 | 59 | /** 60 | * Executes a shell script build on a Swarm agent that has disconnected while the Jenkins 61 | * controller is still running. 62 | */ 63 | @Test 64 | public void buildShellScriptAfterDisconnect() throws Exception { 65 | Node node = swarmClientRule.createSwarmClient("-disableClientsUniqueId"); 66 | 67 | WorkflowJob project = j.createProject(WorkflowJob.class); 68 | project.setConcurrentBuild(false); 69 | project.setDefinition(new CpsFlowDefinition(getFlow(node, 1), true)); 70 | 71 | WorkflowRun build = project.scheduleBuild2(0).waitForStart(); 72 | SemaphoreStep.waitForStart("wait-0/1", build); 73 | swarmClientRule.tearDown(); 74 | 75 | swarmClientRule.createSwarmClientWithName(node.getNodeName(), "-disableClientsUniqueId"); 76 | SemaphoreStep.success("wait-0/1", null); 77 | j.assertBuildStatusSuccess(j.waitForCompletion(build)); 78 | j.assertLogContains("ON_SWARM_CLIENT=true", build); 79 | } 80 | 81 | /** The same as the preceding test, but waits in "sh" rather than "node." */ 82 | @Test 83 | public void buildShellScriptAcrossDisconnect() throws Exception { 84 | Assume.assumeFalse("TODO not sure how to write a corresponding batch script", Functions.isWindows()); 85 | Node node = swarmClientRule.createSwarmClient("-disableClientsUniqueId"); 86 | 87 | WorkflowJob project = j.createProject(WorkflowJob.class); 88 | File f1 = new File(j.jenkins.getRootDir(), "f1"); 89 | File f2 = new File(j.jenkins.getRootDir(), "f2"); 90 | new FileOutputStream(f1).close(); 91 | project.setConcurrentBuild(false); 92 | 93 | String script = "node('" 94 | + node.getNodeName() 95 | + "') {\n" 96 | + " sh '" 97 | + "touch \"" 98 | + f2 99 | + "\";" 100 | + "while [ -f \"" 101 | + f1 102 | + "\" ]; do echo waiting; sleep 1; done;" 103 | + "echo finished waiting;" 104 | + "rm \"" 105 | + f2 106 | + "\"" 107 | + "'\n" 108 | + "echo 'OK, done'\n" 109 | + "}"; 110 | project.setDefinition(new CpsFlowDefinition(script, true)); 111 | 112 | WorkflowRun build = project.scheduleBuild2(0).waitForStart(); 113 | while (!f2.isFile()) { 114 | Thread.sleep(100L); 115 | } 116 | j.waitForMessage("waiting", build); 117 | assertTrue(build.isBuilding()); 118 | Computer computer = node.toComputer(); 119 | assertNotNull(computer); 120 | swarmClientRule.tearDown(); 121 | while (computer.isOnline()) { 122 | Thread.sleep(100L); 123 | } 124 | 125 | swarmClientRule.createSwarmClientWithName(node.getNodeName(), "-disableClientsUniqueId"); 126 | while (computer.isOffline()) { 127 | Thread.sleep(100L); 128 | } 129 | assertTrue(f2.isFile()); 130 | assertTrue(f1.delete()); 131 | while (f2.isFile()) { 132 | Thread.sleep(100L); 133 | } 134 | 135 | j.assertBuildStatusSuccess(j.waitForCompletion(build)); 136 | j.assertLogContains("finished waiting", build); 137 | j.assertLogContains("OK, done", build); 138 | } 139 | 140 | static String getFlow(Node node, int numSemaphores) { 141 | StringBuilder sb = new StringBuilder(); 142 | sb.append("node('").append(node.getNodeName()).append("') {\n"); 143 | for (int i = 0; i < numSemaphores; i++) { 144 | sb.append(" semaphore 'wait-").append(i).append("'\n"); 145 | } 146 | sb.append(" isUnix() ? sh('echo ON_SWARM_CLIENT=$ON_SWARM_CLIENT') : bat('echo" 147 | + " ON_SWARM_CLIENT=%ON_SWARM_CLIENT%')"); 148 | sb.append("}\n"); 149 | 150 | return sb.toString(); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /plugin/src/test/java/hudson/plugins/swarm/AuthorizationStrategyTest.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import static org.junit.Assert.assertEquals; 4 | 5 | import com.michelin.cio.hudson.plugins.rolestrategy.RoleBasedAuthorizationStrategy; 6 | import hudson.model.Node; 7 | import hudson.plugins.swarm.test.SwarmClientRule; 8 | import hudson.security.AuthorizationStrategy; 9 | import hudson.security.FullControlOnceLoggedInAuthorizationStrategy; 10 | import hudson.security.GlobalMatrixAuthorizationStrategy; 11 | import hudson.security.ProjectMatrixAuthorizationStrategy; 12 | import java.io.Writer; 13 | import java.nio.charset.StandardCharsets; 14 | import java.nio.file.Files; 15 | import java.nio.file.Path; 16 | import java.util.HashSet; 17 | import java.util.Set; 18 | import org.apache.commons.lang.RandomStringUtils; 19 | import org.junit.ClassRule; 20 | import org.junit.Rule; 21 | import org.junit.Test; 22 | import org.junit.rules.TemporaryFolder; 23 | import org.jvnet.hudson.test.BuildWatcher; 24 | import org.jvnet.hudson.test.JenkinsRule; 25 | 26 | public class AuthorizationStrategyTest { 27 | 28 | @ClassRule 29 | public static BuildWatcher buildWatcher = new BuildWatcher(); 30 | 31 | @Rule(order = 10) 32 | public JenkinsRule j = new JenkinsRule(); 33 | 34 | @Rule(order = 20) 35 | public TemporaryFolder temporaryFolder = 36 | TemporaryFolder.builder().assureDeletion().build(); 37 | 38 | @Rule(order = 30) 39 | public SwarmClientRule swarmClientRule = new SwarmClientRule(() -> j, temporaryFolder); 40 | 41 | @Test 42 | public void anyoneCanDoAnything() throws Exception { 43 | AuthorizationStrategy authorizationStrategy = AuthorizationStrategy.UNSECURED; 44 | swarmClientRule 45 | .globalSecurityConfigurationBuilder() 46 | .setSwarmUsername(null) 47 | .setSwarmPassword(null) 48 | .setSwarmToken(false) 49 | .setAuthorizationStrategy(authorizationStrategy) 50 | .build(); 51 | addRemoveLabelsViaFileWithUniqueIdLong(); 52 | } 53 | 54 | @Test 55 | public void loggedInUsersCanDoAnythingAnonymousRead() throws Exception { 56 | FullControlOnceLoggedInAuthorizationStrategy authorizationStrategy = 57 | new FullControlOnceLoggedInAuthorizationStrategy(); 58 | authorizationStrategy.setAllowAnonymousRead(true); 59 | swarmClientRule 60 | .globalSecurityConfigurationBuilder() 61 | .setAuthorizationStrategy(authorizationStrategy) 62 | .build(); 63 | addRemoveLabelsViaFileWithUniqueIdLong(); 64 | } 65 | 66 | @Test 67 | public void loggedInUsersCanDoAnythingNoAnonymousRead() throws Exception { 68 | FullControlOnceLoggedInAuthorizationStrategy authorizationStrategy = 69 | new FullControlOnceLoggedInAuthorizationStrategy(); 70 | authorizationStrategy.setAllowAnonymousRead(false); 71 | swarmClientRule 72 | .globalSecurityConfigurationBuilder() 73 | .setAuthorizationStrategy(authorizationStrategy) 74 | .build(); 75 | addRemoveLabelsViaFileWithUniqueIdLong(); 76 | } 77 | 78 | @Test 79 | public void matrixBasedSecurity() throws Exception { 80 | GlobalMatrixAuthorizationStrategy authorizationStrategy = new GlobalMatrixAuthorizationStrategy(); 81 | swarmClientRule 82 | .globalSecurityConfigurationBuilder() 83 | .setAuthorizationStrategy(authorizationStrategy) 84 | .build(); 85 | addRemoveLabelsViaFileWithUniqueIdLong(); 86 | } 87 | 88 | @Test 89 | public void projectBasedMatrixAuthorizationStrategy() throws Exception { 90 | ProjectMatrixAuthorizationStrategy authorizationStrategy = new ProjectMatrixAuthorizationStrategy(); 91 | swarmClientRule 92 | .globalSecurityConfigurationBuilder() 93 | .setAuthorizationStrategy(authorizationStrategy) 94 | .build(); 95 | addRemoveLabelsViaFileWithUniqueIdLong(); 96 | } 97 | 98 | @Test 99 | public void roleBasedStrategy() throws Exception { 100 | RoleBasedAuthorizationStrategy authorizationStrategy = new RoleBasedAuthorizationStrategy(); 101 | swarmClientRule 102 | .globalSecurityConfigurationBuilder() 103 | .setAuthorizationStrategy(authorizationStrategy) 104 | .build(); 105 | addRemoveLabelsViaFileWithUniqueIdLong(); 106 | } 107 | 108 | /** This test exercises all of the API endpoints, including all permission checks. */ 109 | private void addRemoveLabelsViaFileWithUniqueIdLong() throws Exception { 110 | Set labelsToRemove = new HashSet<>(); 111 | labelsToRemove.add(RandomStringUtils.randomAlphanumeric(350)); 112 | labelsToRemove.add(RandomStringUtils.randomAlphanumeric(350)); 113 | labelsToRemove.add(RandomStringUtils.randomAlphanumeric(350)); 114 | 115 | Set labelsToAdd = new HashSet<>(); 116 | labelsToAdd.add(RandomStringUtils.randomAlphanumeric(350)); 117 | labelsToAdd.add(RandomStringUtils.randomAlphanumeric(350)); 118 | labelsToAdd.add(RandomStringUtils.randomAlphanumeric(350)); 119 | 120 | Path labelsFile = Files.createTempFile(temporaryFolder.getRoot().toPath(), "labelsFile", ".txt"); 121 | 122 | Node node = swarmClientRule.createSwarmClient( 123 | "-labelsFile", labelsFile.toAbsolutePath().toString(), "-labels", encode(labelsToRemove)); 124 | 125 | String origLabels = node.getLabelString(); 126 | 127 | try (Writer writer = Files.newBufferedWriter(labelsFile, StandardCharsets.UTF_8)) { 128 | writer.write(encode(labelsToAdd)); 129 | } 130 | 131 | // TODO: This is a bit racy, since updates are not atomic. 132 | while (node.getLabelString().equals(origLabels) 133 | || decode(node.getLabelString()).equals(decode("swarm"))) { 134 | Thread.sleep(100L); 135 | } 136 | 137 | Set expected = new HashSet<>(labelsToAdd); 138 | expected.add("swarm"); 139 | assertEquals(expected, decode(node.getLabelString())); 140 | } 141 | 142 | private static String encode(Set labels) { 143 | return String.join(" ", labels); 144 | } 145 | 146 | private static Set decode(String labels) { 147 | return Set.of(labels.split("\\s+")); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /plugin/src/test/java/hudson/plugins/swarm/test/GlobalSecurityConfigurationBuilder.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm.test; 2 | 3 | import com.michelin.cio.hudson.plugins.rolestrategy.Role; 4 | import com.michelin.cio.hudson.plugins.rolestrategy.RoleBasedAuthorizationStrategy; 5 | import com.michelin.cio.hudson.plugins.rolestrategy.RoleMap; 6 | import hudson.model.Computer; 7 | import hudson.model.User; 8 | import hudson.security.AuthorizationStrategy; 9 | import hudson.security.GlobalMatrixAuthorizationStrategy; 10 | import hudson.security.HudsonPrivateSecurityRealm; 11 | import hudson.security.ProjectMatrixAuthorizationStrategy; 12 | import hudson.security.csrf.DefaultCrumbIssuer; 13 | import java.io.IOException; 14 | import java.util.Map; 15 | import java.util.Set; 16 | import java.util.SortedMap; 17 | import java.util.TreeMap; 18 | import jenkins.model.Jenkins; 19 | import org.jenkinsci.plugins.matrixauth.AuthorizationType; 20 | import org.jenkinsci.plugins.matrixauth.PermissionEntry; 21 | 22 | /** 23 | * A helper class that configures the test Jenkins controller with various realistic global security 24 | * configurations. 25 | */ 26 | public class GlobalSecurityConfigurationBuilder { 27 | 28 | private final SwarmClientRule swarmClientRule; 29 | 30 | private String adminUsername = "admin"; 31 | private String adminPassword = "admin"; 32 | private String swarmUsername = "swarm"; 33 | private String swarmPassword = "honeycomb"; 34 | private boolean swarmToken = true; 35 | private boolean csrf = true; 36 | private AuthorizationStrategy authorizationStrategy = new ProjectMatrixAuthorizationStrategy(); 37 | 38 | public GlobalSecurityConfigurationBuilder(SwarmClientRule swarmClientRule) { 39 | this.swarmClientRule = swarmClientRule; 40 | } 41 | 42 | public GlobalSecurityConfigurationBuilder setAdminUsername(String adminUsername) { 43 | this.adminUsername = adminUsername; 44 | return this; 45 | } 46 | 47 | public GlobalSecurityConfigurationBuilder setAdminPassword(String adminPassword) { 48 | this.adminPassword = adminPassword; 49 | return this; 50 | } 51 | 52 | public GlobalSecurityConfigurationBuilder setSwarmUsername(String swarmUsername) { 53 | this.swarmUsername = swarmUsername; 54 | return this; 55 | } 56 | 57 | public GlobalSecurityConfigurationBuilder setSwarmPassword(String swarmPassword) { 58 | this.swarmPassword = swarmPassword; 59 | return this; 60 | } 61 | 62 | public GlobalSecurityConfigurationBuilder setSwarmToken(boolean swarmToken) { 63 | this.swarmToken = swarmToken; 64 | return this; 65 | } 66 | 67 | public GlobalSecurityConfigurationBuilder setCsrf(boolean csrf) { 68 | this.csrf = csrf; 69 | return this; 70 | } 71 | 72 | public GlobalSecurityConfigurationBuilder setAuthorizationStrategy(AuthorizationStrategy authorizationStrategy) { 73 | this.authorizationStrategy = authorizationStrategy; 74 | return this; 75 | } 76 | 77 | public void build() throws IOException { 78 | // Configure the security realm. 79 | HudsonPrivateSecurityRealm realm = new HudsonPrivateSecurityRealm(false, false, null); 80 | 81 | realm.createAccount(adminUsername, adminPassword); 82 | 83 | User swarmUser = null; 84 | if (swarmUsername != null && swarmPassword != null) { 85 | swarmUser = realm.createAccount(swarmUsername, swarmPassword); 86 | } 87 | swarmClientRule.swarmUsername = swarmUsername; 88 | swarmClientRule.swarmPassword = swarmPassword; 89 | 90 | if (swarmToken) { 91 | String token = swarmClientRule.j.get().createApiToken(swarmUser); 92 | swarmUser.save(); 93 | swarmClientRule.swarmPassword = token; 94 | } 95 | 96 | swarmClientRule.j.get().jenkins.setSecurityRealm(realm); 97 | 98 | // Configure the authorization strategy. 99 | if (authorizationStrategy instanceof GlobalMatrixAuthorizationStrategy) { 100 | GlobalMatrixAuthorizationStrategy strategy = (GlobalMatrixAuthorizationStrategy) authorizationStrategy; 101 | // Not needed for testing, but is helpful when debugging test failures. 102 | strategy.add(Jenkins.ADMINISTER, new PermissionEntry(AuthorizationType.USER, adminUsername)); 103 | 104 | // Needed for the [optional] Jenkins version check as well as CSRF. 105 | strategy.add(Jenkins.READ, new PermissionEntry(AuthorizationType.GROUP, "authenticated")); 106 | 107 | // Needed to create and connect the agent. 108 | strategy.add(Computer.CREATE, new PermissionEntry(AuthorizationType.USER, swarmUsername)); 109 | strategy.add(Computer.CONNECT, new PermissionEntry(AuthorizationType.USER, swarmUsername)); 110 | 111 | /* 112 | * The following is necessary because 113 | * AuthorizationMatrixNodeProperty.NodeListenerImpl#onCreated only applies to 114 | * ProjectMatrixAuthorizationStrategy. 115 | */ 116 | if (!(authorizationStrategy instanceof ProjectMatrixAuthorizationStrategy)) { 117 | // Needed to add/remove labels after the fact. 118 | strategy.add(Computer.CONFIGURE, new PermissionEntry(AuthorizationType.USER, swarmUsername)); 119 | } 120 | } else if (authorizationStrategy instanceof RoleBasedAuthorizationStrategy) { 121 | Role admin = new Role("admin", ".*", Set.of(Jenkins.ADMINISTER.getId()), "Jenkins administrators"); 122 | Role readOnly = new Role("readonly", ".*", Set.of(Jenkins.READ.getId()), "Read-only users"); 123 | Role swarm = new Role( 124 | "swarm", 125 | ".*", 126 | Set.of(Computer.CREATE.getId(), Computer.CONNECT.getId(), Computer.CONFIGURE.getId()), 127 | "Swarm users"); 128 | 129 | SortedMap> roleMap = 130 | new TreeMap<>(); 131 | roleMap.put( 132 | admin, 133 | Set.of(new com.michelin.cio.hudson.plugins.rolestrategy.PermissionEntry( 134 | com.michelin.cio.hudson.plugins.rolestrategy.AuthorizationType.USER, adminUsername))); 135 | roleMap.put( 136 | readOnly, 137 | Set.of(new com.michelin.cio.hudson.plugins.rolestrategy.PermissionEntry( 138 | com.michelin.cio.hudson.plugins.rolestrategy.AuthorizationType.GROUP, "authenticated"))); 139 | roleMap.put( 140 | swarm, 141 | Set.of(new com.michelin.cio.hudson.plugins.rolestrategy.PermissionEntry( 142 | com.michelin.cio.hudson.plugins.rolestrategy.AuthorizationType.USER, swarmUsername))); 143 | 144 | authorizationStrategy = new RoleBasedAuthorizationStrategy( 145 | Map.of(RoleBasedAuthorizationStrategy.GLOBAL, new RoleMap(roleMap))); 146 | } 147 | swarmClientRule.j.get().jenkins.setAuthorizationStrategy(authorizationStrategy); 148 | 149 | // Configure CSRF. 150 | if (csrf) { 151 | swarmClientRule.j.get().jenkins.setCrumbIssuer(new DefaultCrumbIssuer(false)); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Swarm Plugin 2 | :toc: 3 | :toc-placement!: 4 | :toc-title: 5 | ifdef::env-github[] 6 | :tip-caption: :bulb: 7 | :note-caption: :information_source: 8 | :important-caption: :heavy_exclamation_mark: 9 | :caution-caption: :fire: 10 | :warning-caption: :warning: 11 | endif::[] 12 | 13 | https://ci.jenkins.io/job/Plugins/job/swarm-plugin/job/master/[image:https://ci.jenkins.io/job/Plugins/job/swarm-plugin/job/master/badge/icon[Build Status]] 14 | https://github.com/jenkinsci/swarm-plugin/graphs/contributors[image:https://img.shields.io/github/contributors/jenkinsci/swarm-plugin.svg[Contributors]] 15 | https://plugins.jenkins.io/swarm[image:https://img.shields.io/jenkins/plugin/v/swarm.svg[Jenkins Plugin]] 16 | https://github.com/jenkinsci/swarm-plugin/releases/latest[image:https://img.shields.io/github/release/jenkinsci/swarm-plugin.svg?label=changelog[GitHub release]] 17 | https://plugins.jenkins.io/swarm[image:https://img.shields.io/jenkins/plugin/i/swarm.svg?color=blue[Jenkins Plugin Installs]] 18 | 19 | toc::[] 20 | 21 | == Introduction 22 | 23 | The Swarm plugin enables nodes to join a nearby Jenkins controller, thereby forming an ad-hoc cluster. 24 | This plugin makes it easier to scale a Jenkins cluster by spinning up and tearing down new agents, enabling team members to contribute compute resources to a build farm or to attach agents to which the Jenkins controller cannot initiate connections. 25 | 26 | This plugin consists of two pieces: 27 | 28 | . A self-contained client that can join an existing Jenkins controller. 29 | . A plugin that needs to be installed on the Jenkins controller to accept Swarm clients. 30 | 31 | With the Swarm client, a person who is willing to contribute some of his computing power to the cluster just needs to run a virtual machine with the Swarm client and the cluster gets an additional agent. 32 | Because the Swarm client is running on a separate VM, there is no need to worry about the builds/tests interfering with the host system or altering its settings unexpectedly. 33 | 34 | == Getting started 35 | 36 | . Install the Swarm plugin from the Update Center. 37 | . Ensure your agent is running version 11 or later of the Java Runtime Environment (JRE). The recommendation is to use the same JRE distribution and version as the controller. 38 | . Download the Swarm client from `${JENKINS_URL}/swarm/swarm-client.jar` on your agent. 39 | . Run the Swarm client with `java -jar path/to/swarm-client.jar -url ${JENKINS_URL} -username ${USERNAME}` and one of the authentication options as described in xref:docs/security.adoc#authentication[the Global Security Configuration documentation]. There are no other required command-line options; run with the `-help` option to see the available options. 40 | 41 | == Documentation 42 | 43 | * xref:CHANGELOG.adoc[Changelog] 44 | * xref:docs/logging.adoc[Logging and Diagnostics] 45 | * xref:docs/prometheus.adoc[Prometheus Monitoring] 46 | * xref:docs/proxy.adoc[Proxy Configuration] 47 | * xref:docs/security.adoc[Global Security Configuration] 48 | * xref:docs/configfile.adoc[YAML Configuration] 49 | 50 | == Available options 51 | 52 | `$ java -jar swarm-client.jar -help` 53 | 54 | [cols="1,1",options="header"] 55 | |=== 56 | |Name |Description 57 | |`-config FILE` |YAML configuration file containing the options. 58 | |`-deleteExistingClients` |Delete any existing agent with the same name. (default: false) 59 | |`-description VAL` |Description to be put on the agent. 60 | |`-disableClientsUniqueId` |Disable client's unique ID. (default: false) 61 | |`-disableSslVerification` |Disable SSL verification in the HTTP client. (default: false) 62 | |`-disableWorkDir` |Disable Remoting working directory support and run the agent in legacy mode. (default: false) 63 | |`-e (--env)` |An environment variable to be defined on this agent. It is specified as `key=value'. Multiple variables are allowed. 64 | |`-executors N` |Number of executors (default: number of CPUs) 65 | |`-failIfWorkDirIsMissing` |Fail if the requested Remoting working directory or internal directory is missing. (default: false) 66 | |`-fsroot FILE` |Remote root directory. (default: .) 67 | |`-help (--help, -h)` |Show the help screen (default: false) 68 | |`-internalDir FILE` |The name of the directory within the Remoting working directory where files internal to Remoting will be stored. 69 | |`-jar-cache FILE` |Cache directory that stores JAR files sent from the controller. 70 | |`-keepDisconnectedClients` |Do not remove clients from the controller when the agent becomes disconnected. (default: false) 71 | |`-labels VAL` |Whitespace-separated list of labels to be assigned for this agent. Multiple options are allowed. 72 | |`-labelsFile VAL` |File location with space delimited list of labels. If the file changes, the client is restarted. 73 | |`-maxRetryInterval N` |Max time to wait before retry in seconds. Default is 60 seconds. (default: 60) 74 | |`-mode MODE` |The mode controlling how Jenkins allocates jobs to agents. Can be either `normal' (use this node as much as possible) or `exclusive' (only build jobs with label expressions matching this node). Default is `normal'. (default: normal) 75 | |`-name VAL` |Name of the agent. 76 | |`-noRetryAfterConnected` |Do not retry if a successful connection gets closed. (default: false) 77 | |`-password VAL` |The Jenkins user API token or password. 78 | |`-passwordEnvVariable VAL` |Environment variable containing the Jenkins user API token or password. 79 | |`-passwordFile VAL` |File containing the Jenkins user API token or password. 80 | |`-pidFile VAL` |File to write PID to. The client will refuse to start if this file exists and the previous process is still running. 81 | |`-prometheusPort N` |If defined, then start an HTTP service on this port for Prometheus metrics. (default: -1) 82 | |`-retry N` |Number of retries before giving up. Unlimited if not specified. (default: -1) 83 | |`-retryBackOffStrategy RETRY_BACK_OFF_STRATEGY` |The mode controlling retry wait time. Can be either `none' (use same interval between retries) or `linear' (increase wait time before each retry up to maxRetryInterval) or `exponential' (double wait interval on each retry up to maxRetryInterval). Default is `none'. (default: NONE) 84 | |`-retryInterval N` |Time to wait before retry in seconds. Default is 10 seconds. (default: 10) 85 | |`-sslFingerprints VAL` |Whitespace-separated list of accepted certificate fingerprints (SHA-256/Hex), otherwise system truststore will be used. No revocation, expiration or not yet valid check will be performed for custom fingerprints! Multiple options are allowed. (default: ) 86 | |`-t (--toolLocation)` |A tool location to be defined on this agent. It is specified as `toolName=location'. 87 | |`-tunnel VAL` |Connect to the specified host and port, instead of connecting directly to Jenkins. Useful when connection to Jenkins needs to be tunneled. Can be also HOST: or :PORT, in which case the missing portion will be auto-configured like the default behavior 88 | |`-url (-master) VAL` |The complete target Jenkins URL like `http://server:8080/jenkins/'. 89 | |`-username VAL` |The Jenkins username for authentication. 90 | |`-webSocket` |Connect using the WebSocket protocol. (default: false) 91 | |`-webSocketHeader NAME=VALUE` |Additional WebSocket header to set, e.g. for authenticating with reverse proxies. To specify multiple headers, call this flag multiple times, one with each header. 92 | |`-workDir FILE` |The Remoting working directory where the JAR cache and logs will be stored. 93 | |=== 94 | 95 | == Issues 96 | 97 | Report issues and enhancements in the https://issues.jenkins.io/[Jenkins issue tracker]. Use the `swarm-plugin` component in the `JENKINS` project. 98 | 99 | == Contributing 100 | 101 | Refer to our https://github.com/jenkinsci/.github/blob/master/CONTRIBUTING.md[contribution guidelines]. 102 | -------------------------------------------------------------------------------- /docs/security.adoc: -------------------------------------------------------------------------------- 1 | = Global Security Configuration 2 | :toc: 3 | :toc-title: 4 | ifdef::env-github[] 5 | :tip-caption: :bulb: 6 | :note-caption: :information_source: 7 | :important-caption: :heavy_exclamation_mark: 8 | :caution-caption: :fire: 9 | :warning-caption: :warning: 10 | endif::[] 11 | 12 | == Overview 13 | 14 | === Authentication 15 | 16 | Swarm may be used with either a https://www.jenkins.io/blog/2018/07/02/new-api-token-system/[Jenkins API token] (recommended) or a password. 17 | The following command-line options control authentication: 18 | 19 | `-username`:: The Jenkins username for authentication. 20 | `-password`:: The Jenkins user API token or password. 21 | `-passwordEnvVariable`:: Environment variable containing the Jenkins user API token or password. 22 | `-passwordFile`:: File containing the Jenkins user API token or password. 23 | 24 | NOTE: When using a password, the Swarm client will automatically obtain a valid https://support.cloudbees.com/hc/en-us/articles/219257077-CSRF-Protection-Explained[CSRF crumb] when making requests. 25 | 26 | === Authorization 27 | 28 | Swarm requires a user with the following permissions: 29 | 30 | * *Overall/Read* 31 | * *Agent/Create* 32 | * *Agent/Connect* 33 | * *Agent/Configure* (_not_ required when using the project-based Matrix Authorization Strategy) 34 | 35 | == Examples 36 | 37 | === Matrix-based security 38 | 39 | A common practice is to grant *Overall/Read* permission to either anonymous or authenticated users, leaving the dedicated Swarm user with only *Agent/Create*, *Agent/Connect*, and *Agent/Configure* permissions: 40 | 41 | image:images/matrixBasedSecurity.png[image] 42 | 43 | === Project-based Matrix Authorization Strategy 44 | 45 | A common practice is to grant *Overall/Read* permission to either anonymous or authenticated users, leaving the dedicated Swarm user with only *Agent/Create* and *Agent/Connect* permissions: 46 | 47 | image:images/projectBasedMatrixAuthorizationStrategy.png[image] 48 | 49 | NOTE: Unlike matrix-based security, the project-based Matrix Authorization Strategy does not require *Agent/Configure* permission. 50 | 51 | === Role-Based Strategy 52 | 53 | A common practice is to create a read-only role with *Overall/Read* permission, leaving a dedicated Swarm role with only *Agent/Create*, *Agent/Connect*, and *Agent/Configure* permissions: 54 | 55 | image:images/roleBasedStrategyManage.png[image] 56 | 57 | The read-only role can then be assigned to either anonymous or authenticated users, leaving the dedicated Swarm role for Swarm users only: 58 | 59 | image:images/roleBasedStrategyAssign.png[image] 60 | 61 | == Caveats and Troubleshooting 62 | 63 | A single Jenkins controller currently supports a single selection from a multitude of possible authentication providers. 64 | If your Jenkins deployment outgrows locally-defined accounts and switches to an external database, such as LDAP, GitHub or Google authentication, you would have to define there not only team member accounts, but also some for your Swarm agents to use. 65 | 66 | === GitHub Authentication plugin 67 | 68 | With "GitHub Authentication Plugin" selected as the active Security Realm implementation for a Jenkins controller, it is no longer possible to authenticate the Swarm client with an account defined only locally on the Jenkins controller with a password (since the local database was no longer selected to be used for authentication), although this account is otherwise known and manageable in Jenkins UI. 69 | 70 | As tracked in https://issues.jenkins.io/browse/JENKINS-63421[issue JENKINS-63421] currently the "GitHub Committer Authorization Strategy" delivered as an optional part of the https://plugins.jenkins.io/github-oauth/[GitHub Authentication plugin] (`github-oauth-plugin`) lacks a way to assign *Agent/...* permissions. 71 | Due to this, you would have to define a Matrix-based or Project-based Matrix strategy as detailed above, and use the account (and group/organization) names provided and confirmed by its Authentication part as your enabled Security Realm. 72 | 73 | NOTE: This can forfeit some benefits compared to the native strategy provided by the plugin: it is questionable whether the matrix setup can go into such detail as `github-oauth-plugin` natively can, e.g. considering PR authors, collaborators, contributors, etc as special groups). 74 | 75 | ==== Primary setup 76 | 77 | Swarm clients can connect to the Jenkins controller using a GitHub account, and passing the token string with one of the `-password*` CLI options for the Swarm agent JAR startup. 78 | 79 | On the Jenkins controller side, the active authorization strategy has to be switched from "GitHub Committer Authorization Strategy" to a manually made somewhat equivalent Matrix. 80 | It would be statically listing names of your project's GitHub organizations and teams of admin and other accounts as groups with specific rights. 81 | Finally, ensure that the name of GitHub account prepared for Swarm connections is granted the permissions needed for Swarm client, as detailed above. 82 | 83 | WARNING: Be sure to not lock yourself out in the process -- perhaps adding a Matrix strategy entry for your GitHub username explicitly, assigning all privileges to it, and applying that change as the first step. 84 | 85 | On the GitHub side, it suffices to generate a token with minimal permission set needed for authentication, per requirements of the GitHub Authentication plugin -- currently one needs to have the `read:user` and `read:org` permissions (and possibly `user:email`). 86 | 87 | NOTE: Authentication to the GitHub API directly by password no longer works since 2020, only by tokens. 88 | The upside is that this allows the administrator to issue (and revoke) tokens for many build agents using the same GitHub account and to limit the impact of a breach using a minimally privileged token. 89 | 90 | ==== Recommendations about tokens 91 | 92 | For easier management and revocation of tokens, take time to comment the token in the optional Note field (e.g. which team member or pre-built image is it delegated to, and when) while you are generating it. 93 | 94 | This is particularly important if tokens are later used on machines not managed by the Jenkins build farm administrators, e.g. team members or project users contributing resources to help a project. 95 | 96 | If you distribute pre-built images for containers or virtual machines that can be used as workers for your project, it is reasonable to ensure that recent and secure software is used on systems connecting to your Jenkins controller, by pre-installing different tokens over time and phasing out access for old ones -- as those images eventually become insecure. 97 | 98 | ==== Recommendations about membership 99 | 100 | It is recommended to keep the GitHub account for authenticating Swarm connections outside your project's GitHub organization for several reasons: 101 | 102 | * so it has no rights there assigned by accident or abused by a security breach; 103 | * even (or especially) if your organizations or projects are private, *this* account does not need to access them for its work -- it only needs to exist and successfully authenticate with GitHub API; 104 | * the Matrix-based security configuration with GitHub Authentication plugin supports usernames as Jenkins accounts, and organization names and `org*team` notation as Jenkins groups; while it is reasonable to allow any members of the specified organization various permissions for the Jenkins jobs and other objects, an account used purely for agent connections does not need those (and should not inherit them by being an org member). 105 | 106 | ==== Further notes 107 | 108 | Even if the worker machine is isolated by firewall, so that it can not access the Internet generally and can only talk to the Jenkins controller (using both HTTP/HTTPS and the "TCP port for inbound agents"), the GitHub authentication still works -- since it is not the Swarm client that has to go to GitHub and back to confirm the account. 109 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/Options.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import java.io.File; 4 | import java.util.ArrayList; 5 | import java.util.List; 6 | import java.util.Map; 7 | import org.kohsuke.args4j.Option; 8 | import org.kohsuke.args4j.spi.MapOptionHandler; 9 | 10 | public class Options { 11 | 12 | @Option(name = "-name", usage = "Name of the agent.") 13 | public String name; 14 | 15 | @Option(name = "-description", usage = "Description to be put on the agent.") 16 | public String description; 17 | 18 | @Option( 19 | name = "-labels", 20 | usage = "Whitespace-separated list of labels to be assigned for this agent. Multiple" 21 | + " options are allowed.") 22 | public List labels = new ArrayList<>(); 23 | 24 | @Option(name = "-fsroot", usage = "Remote root directory.") 25 | public File fsroot = new File("."); 26 | 27 | @Option(name = "-executors", usage = "Number of executors") 28 | public int executors = Runtime.getRuntime().availableProcessors(); 29 | 30 | @Option( 31 | name = "-url", 32 | aliases = "-master", 33 | usage = "The complete target Jenkins URL like 'http://server:8080/jenkins/'.") 34 | public String url; 35 | 36 | @Option( 37 | name = "-tunnel", 38 | usage = "Connect to the specified host and port, instead of connecting directly to" 39 | + " Jenkins. Useful when connection to Jenkins needs to be tunneled. Can be" 40 | + " also HOST: or :PORT, in which case the missing portion will be" 41 | + " auto-configured like the default behavior") 42 | public String tunnel; 43 | 44 | @Option(name = "-webSocket", usage = "Connect using the WebSocket protocol.", forbids = "-tunnel") 45 | public boolean webSocket; 46 | 47 | @Option( 48 | name = "-webSocketHeader", 49 | usage = "Additional WebSocket header to set, e.g. for authenticating with reverse" 50 | + " proxies. To specify multiple headers, call this flag multiple times," 51 | + " one with each header.", 52 | metaVar = "NAME=VALUE", 53 | depends = "-webSocket") 54 | public Map webSocketHeaders; 55 | 56 | @Option(name = "-noRetryAfterConnected", usage = "Do not retry if a successful connection gets closed.") 57 | public boolean noRetryAfterConnected; 58 | 59 | @Option(name = "-retry", usage = "Number of retries before giving up. Unlimited if not specified.") 60 | public int retry = -1; 61 | 62 | @Option( 63 | name = "-retryBackOffStrategy", 64 | usage = "The mode controlling retry wait time. Can be either 'none' (use same interval" 65 | + " between retries) or 'linear' (increase wait time before each retry up" 66 | + " to maxRetryInterval) or 'exponential' (double wait interval on each" 67 | + " retry up to maxRetryInterval). Default is 'none'.", 68 | handler = RetryBackOffStrategyOptionHandler.class) 69 | public RetryBackOffStrategy retryBackOffStrategy = RetryBackOffStrategy.NONE; 70 | 71 | @Option(name = "-retryInterval", usage = "Time to wait before retry in seconds. Default is 10 seconds.") 72 | public int retryInterval = 10; 73 | 74 | @Option(name = "-maxRetryInterval", usage = "Max time to wait before retry in seconds. Default is 60 seconds.") 75 | public int maxRetryInterval = 60; 76 | 77 | @Option(name = "-disableSslVerification", usage = "Disable SSL verification in the HTTP client.") 78 | public boolean disableSslVerification; 79 | 80 | @Option( 81 | name = "-sslFingerprints", 82 | usage = "Whitespace-separated list of accepted certificate fingerprints (SHA-256/Hex), " 83 | + "otherwise system truststore will be used. " 84 | + "No revocation, expiration or not yet valid check will be performed " 85 | + "for custom fingerprints! Multiple options are allowed.") 86 | public String sslFingerprints = ""; 87 | 88 | @Option(name = "-disableClientsUniqueId", usage = "Disable client's unique ID.") 89 | public boolean disableClientsUniqueId; 90 | 91 | @Option(name = "-deleteExistingClients", usage = "Delete any existing agent with the same name.") 92 | public boolean deleteExistingClients; 93 | 94 | @Option( 95 | name = "-keepDisconnectedClients", 96 | usage = "Do not remove clients from the controller when the agent becomes disconnected.") 97 | public boolean keepDisconnectedClients; 98 | 99 | @Option( 100 | name = "-mode", 101 | usage = "The mode controlling how Jenkins allocates jobs to agents. Can be either '" 102 | + ModeOptionHandler.NORMAL 103 | + "' (use this node as much as possible) or '" 104 | + ModeOptionHandler.EXCLUSIVE 105 | + "' (only build jobs with label expressions matching this node)." 106 | + " Default is '" 107 | + ModeOptionHandler.NORMAL 108 | + "'.", 109 | handler = ModeOptionHandler.class) 110 | public String mode = ModeOptionHandler.NORMAL; 111 | 112 | @Option( 113 | name = "-t", 114 | aliases = "--toolLocation", 115 | usage = "A tool location to be defined on this agent. It is specified as 'toolName=location'.", 116 | handler = MapOptionHandler.class) 117 | public Map toolLocations; 118 | 119 | @Option( 120 | name = "-e", 121 | aliases = "--env", 122 | usage = "An environment variable to be defined on this agent. It is specified as" 123 | + " 'key=value'. Multiple variables are allowed.", 124 | handler = MapOptionHandler.class) 125 | public Map environmentVariables; 126 | 127 | @Option(name = "-username", usage = "The Jenkins username for authentication.") 128 | public String username; 129 | 130 | @Option(name = "-password", usage = "The Jenkins user API token or password.") 131 | @SuppressWarnings("lgtm[jenkins/plaintext-storage]") 132 | public String password; 133 | 134 | @Option( 135 | name = "-help", 136 | aliases = {"--help", "-h"}, 137 | help = true, 138 | usage = "Show the help screen") 139 | public boolean help; 140 | 141 | @Option( 142 | name = "-passwordEnvVariable", 143 | usage = "Environment variable containing the Jenkins user API token or password.") 144 | @SuppressWarnings("lgtm[jenkins/plaintext-storage]") 145 | public String passwordEnvVariable; 146 | 147 | @Option(name = "-passwordFile", usage = "File containing the Jenkins user API token or password.") 148 | @SuppressWarnings("lgtm[jenkins/plaintext-storage]") 149 | public String passwordFile; 150 | 151 | @Option( 152 | name = "-labelsFile", 153 | usage = "File location with space delimited list of labels. If the file changes, the" 154 | + " client is restarted.") 155 | public String labelsFile; 156 | 157 | @Option( 158 | name = "-pidFile", 159 | usage = "File to write PID to. The client will refuse to start if this file exists " 160 | + "and the previous process is still running.") 161 | public String pidFile; 162 | 163 | @Option( 164 | name = "-disableWorkDir", 165 | usage = "Disable Remoting working directory support and run the agent in legacy mode.", 166 | forbids = {"-workDir", "-internalDir", "-failIfWorkDirIsMissing"}) 167 | public boolean disableWorkDir = false; 168 | 169 | @Option( 170 | name = "-workDir", 171 | usage = "The Remoting working directory where the JAR cache and logs will be stored.", 172 | forbids = "-disableWorkDir") 173 | public File workDir; 174 | 175 | @Option( 176 | name = "-internalDir", 177 | usage = "The name of the directory within the Remoting working directory where files" 178 | + " internal to Remoting will be stored.", 179 | forbids = "-disableWorkDir") 180 | public File internalDir; 181 | 182 | @Option(name = "-jar-cache", usage = "Cache directory that stores JAR files sent from the controller.") 183 | public File jarCache; 184 | 185 | @Option( 186 | name = "-failIfWorkDirIsMissing", 187 | usage = "Fail if the requested Remoting working directory or internal directory is missing.", 188 | forbids = "-disableWorkDir") 189 | public boolean failIfWorkDirIsMissing = false; 190 | 191 | @Option( 192 | name = "-prometheusPort", 193 | usage = "If defined, then start an HTTP service on this port for Prometheus metrics.") 194 | public int prometheusPort = -1; 195 | 196 | @Option(name = "-config", usage = "YAML configuration file containing the options.") 197 | public File config; 198 | } 199 | -------------------------------------------------------------------------------- /client/src/test/java/hudson/plugins/swarm/YamlConfigTest.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import static org.hamcrest.CoreMatchers.allOf; 4 | import static org.hamcrest.CoreMatchers.containsString; 5 | import static org.hamcrest.CoreMatchers.equalTo; 6 | import static org.hamcrest.CoreMatchers.hasItems; 7 | import static org.hamcrest.MatcherAssert.assertThat; 8 | import static org.junit.Assert.assertThrows; 9 | import static org.junit.Assert.fail; 10 | 11 | import java.io.ByteArrayInputStream; 12 | import java.nio.charset.StandardCharsets; 13 | import java.util.stream.Stream; 14 | import org.junit.Test; 15 | 16 | public class YamlConfigTest { 17 | @Test 18 | public void loadOptionsFromYaml() throws ConfigurationException { 19 | final String yamlString = "url: http://localhost:8080/jenkins\n" 20 | + "name: agent-name-0\n" 21 | + "description: Configured from yml\n" 22 | + "executors: 3\n" 23 | + "labels:\n" 24 | + " - label-a\n" 25 | + " - label-b\n" 26 | + " - label-c\n" 27 | + "disableClientsUniqueId: true\n" 28 | + "mode: exclusive\n" 29 | + "failIfWorkDirIsMissing: false\n" 30 | + "deleteExistingClients: true\n" 31 | + "keepDisconnectedClients: false\n" 32 | + "labelsFile: ~/l.conf\n" 33 | + "pidFile: ~/s.pid\n" 34 | + "prometheusPort: 112233\n"; 35 | 36 | final Options options = loadYaml(yamlString); 37 | assertThat(options.url, equalTo("http://localhost:8080/jenkins")); 38 | assertThat(options.description, equalTo("Configured from yml")); 39 | assertThat(options.executors, equalTo(3)); 40 | assertThat(options.labels, hasItems("label-a", "label-b", "label-c")); 41 | assertThat(options.disableClientsUniqueId, equalTo(true)); 42 | assertThat(options.mode, equalTo(ModeOptionHandler.EXCLUSIVE)); 43 | assertThat(options.failIfWorkDirIsMissing, equalTo(false)); 44 | assertThat(options.deleteExistingClients, equalTo(true)); 45 | assertThat(options.keepDisconnectedClients, equalTo(false)); 46 | assertThat(options.labelsFile, equalTo("~/l.conf")); 47 | assertThat(options.pidFile, equalTo("~/s.pid")); 48 | assertThat(options.prometheusPort, equalTo(112233)); 49 | } 50 | 51 | @Test 52 | public void defaultValues() throws ConfigurationException { 53 | final Options defaultOptions = new Options(); 54 | defaultOptions.url = "ignore"; 55 | final Options options = loadYaml("url: ignore\n"); 56 | 57 | Stream.of(Options.class.getDeclaredFields()).forEach(f -> { 58 | try { 59 | assertThat("Field " + f.getName(), f.get(options), equalTo(f.get(defaultOptions))); 60 | } catch (IllegalAccessException e) { 61 | fail(e.getMessage()); 62 | } 63 | }); 64 | } 65 | 66 | @Test 67 | public void toolLocationOption() throws ConfigurationException { 68 | final String yamlString = "url: http://localhost:8080/jenkins\n" 69 | + "toolLocations:\n" 70 | + " tool-a: /tool/path/a\n" 71 | + " tool-b: /tool/path/b\n"; 72 | 73 | final Options options = loadYaml(yamlString); 74 | assertThat(options.toolLocations.size(), equalTo(2)); 75 | assertThat(options.toolLocations.get("tool-a"), equalTo("/tool/path/a")); 76 | assertThat(options.toolLocations.get("tool-b"), equalTo("/tool/path/b")); 77 | } 78 | 79 | @Test 80 | public void errorHandlingOptions() throws ConfigurationException { 81 | final String yamlString = "url: http://localhost:8080/jenkins\n" 82 | + "retry: 0\n" 83 | + "noRetryAfterConnected: true\n" 84 | + "retryInterval: 12\n" 85 | + "maxRetryInterval: 9\n"; 86 | 87 | final Options options = loadYaml(yamlString); 88 | assertThat(options.url, equalTo("http://localhost:8080/jenkins")); 89 | assertThat(options.retry, equalTo(0)); 90 | assertThat(options.failIfWorkDirIsMissing, equalTo(false)); 91 | assertThat(options.noRetryAfterConnected, equalTo(true)); 92 | assertThat(options.retryInterval, equalTo(12)); 93 | assertThat(options.maxRetryInterval, equalTo(9)); 94 | } 95 | 96 | @Test 97 | public void environmentVariablesOption() throws ConfigurationException { 98 | final String yamlString = "url: http://localhost:8080/jenkins\n" 99 | + "environmentVariables:\n" 100 | + " ENV_1: env#1\n" 101 | + " ENV_2: env#2\n"; 102 | 103 | final Options options = loadYaml(yamlString); 104 | assertThat(options.environmentVariables.size(), equalTo(2)); 105 | assertThat(options.environmentVariables.get("ENV_1"), equalTo("env#1")); 106 | assertThat(options.environmentVariables.get("ENV_2"), equalTo("env#2")); 107 | } 108 | 109 | @Test 110 | public void authenticationOptions() throws ConfigurationException { 111 | final String yamlString = "url: http://localhost:8080/jenkins\n" 112 | + "disableSslVerification: false\n" 113 | + "sslFingerprints: fp0 fp1 fp2\n" 114 | + "username: swarm-user-name\n" 115 | + "passwordEnvVariable: PASS_ENV\n" 116 | + "passwordFile: ~/p.conf\n"; 117 | 118 | final Options options = loadYaml(yamlString); 119 | assertThat(options.url, equalTo("http://localhost:8080/jenkins")); 120 | assertThat(options.disableSslVerification, equalTo(false)); 121 | assertThat(options.sslFingerprints, equalTo("fp0 fp1 fp2")); 122 | assertThat(options.username, equalTo("swarm-user-name")); 123 | assertThat(options.passwordEnvVariable, equalTo("PASS_ENV")); 124 | assertThat(options.passwordFile, equalTo("~/p.conf")); 125 | } 126 | 127 | @Test 128 | public void webSocketOption() throws ConfigurationException { 129 | final String yamlString = "url: http://localhost:8080/jenkins\nwebSocket: true\n"; 130 | 131 | final Options options = loadYaml(yamlString); 132 | assertThat(options.webSocket, equalTo(true)); 133 | } 134 | 135 | @Test 136 | public void webSocketHeadersOption() throws ConfigurationException { 137 | final String yamlString = "url: http://localhost:8080/jenkins\n" 138 | + "webSocket: true\n" 139 | + "webSocketHeaders:\n" 140 | + " WS_HEADER_0: WS_VALUE_0\n" 141 | + " WS_HEADER_1: WS_VALUE_1\n"; 142 | 143 | final Options options = loadYaml(yamlString); 144 | assertThat(options.webSocketHeaders.size(), equalTo(2)); 145 | assertThat(options.webSocketHeaders.get("WS_HEADER_0"), equalTo("WS_VALUE_0")); 146 | assertThat(options.webSocketHeaders.get("WS_HEADER_1"), equalTo("WS_VALUE_1")); 147 | } 148 | 149 | @Test 150 | public void webSocketHeadersFailsIfWebSocketOptionIsNotSet() { 151 | final String yamlString = "url: http://localhost:8080/jenkins\n" 152 | + "webSocketHeaders:\n" 153 | + " WS_HEADER_0: WS_VALUE_0\n" 154 | + " WS_HEADER_1: WS_VALUE_1\n"; 155 | 156 | final Throwable ex = assertThrows(ConfigurationException.class, () -> loadYaml(yamlString)); 157 | assertThat(ex.getMessage(), allOf(containsString("webSocketHeaders"), containsString("webSocket"))); 158 | } 159 | 160 | @Test 161 | public void retryBackOffStrategyOptionValues() throws ConfigurationException { 162 | final Options none = loadYaml("url: ignore\nretryBackOffStrategy: NONE\n"); 163 | assertThat(none.retryBackOffStrategy, equalTo(RetryBackOffStrategy.NONE)); 164 | 165 | final Options linear = loadYaml("url: ignore\nretryBackOffStrategy: LINEAR\n"); 166 | assertThat(linear.retryBackOffStrategy, equalTo(RetryBackOffStrategy.LINEAR)); 167 | 168 | final Options exponential = loadYaml("url: ignore\nretryBackOffStrategy: EXPONENTIAL\n"); 169 | assertThat(exponential.retryBackOffStrategy, equalTo(RetryBackOffStrategy.EXPONENTIAL)); 170 | } 171 | 172 | @Test 173 | public void modeOptionValues() throws ConfigurationException { 174 | final Options normal = loadYaml("url: ignore\nmode: " + ModeOptionHandler.NORMAL + "\n"); 175 | assertThat(normal.mode, equalTo(ModeOptionHandler.NORMAL)); 176 | final Options exclusive = loadYaml("url: ignore\nmode: " + ModeOptionHandler.EXCLUSIVE + "\n"); 177 | assertThat(exclusive.mode, equalTo(ModeOptionHandler.EXCLUSIVE)); 178 | } 179 | 180 | @Test 181 | public void failIfModeHasInvalidValue() { 182 | final Throwable ex = assertThrows(ConfigurationException.class, () -> loadYaml("url: ignore\nmode: invalid\n")); 183 | assertThat(ex.getMessage(), containsString("mode")); 184 | } 185 | 186 | @Test 187 | public void enumsAreCaseInsensitive() throws ConfigurationException { 188 | final Options upperCase = loadYaml("url: ignore\nretryBackOffStrategy: LINEAR\n"); 189 | assertThat(upperCase.retryBackOffStrategy, equalTo(RetryBackOffStrategy.LINEAR)); 190 | 191 | final Options lowerCase = loadYaml("url: ignore\nretryBackOffStrategy: exponential\n"); 192 | assertThat(lowerCase.retryBackOffStrategy, equalTo(RetryBackOffStrategy.EXPONENTIAL)); 193 | 194 | final Options mixedCase = loadYaml("url: ignore\nretryBackOffStrategy: eXPONenTiaL\n"); 195 | assertThat(mixedCase.retryBackOffStrategy, equalTo(RetryBackOffStrategy.EXPONENTIAL)); 196 | } 197 | 198 | @Test 199 | public void failsIfDeprecatedOptionIsUsed() { 200 | final Throwable ex = assertThrows(ConfigurationException.class, () -> loadYaml("password: should-fail\n")); 201 | assertThat(ex.getMessage(), containsString("password")); 202 | } 203 | 204 | @Test 205 | public void failsOnConflictingOptions() { 206 | final Throwable ex = assertThrows( 207 | ConfigurationException.class, 208 | () -> loadYaml("url: ignore\nwebSocket: true\ntunnel: excluded-by.ws:123\n")); 209 | assertThat(ex.getMessage(), allOf(containsString("webSocket"), containsString("tunnel"))); 210 | } 211 | 212 | @Test 213 | public void failsOnConfigOption() { 214 | final Throwable ex = assertThrows( 215 | ConfigurationException.class, () -> loadYaml("url: ignore\nconfig: no-other-config-allowed.yml\n")); 216 | assertThat(ex.getMessage(), containsString("config")); 217 | } 218 | 219 | @Test 220 | public void failsOnHelpOption() { 221 | final Throwable ex = assertThrows(ConfigurationException.class, () -> loadYaml("url: ignore\nhelp: true\n")); 222 | assertThat(ex.getMessage(), containsString("help")); 223 | } 224 | 225 | private Options loadYaml(String yamlString) throws ConfigurationException { 226 | return new YamlConfig().loadOptions(new ByteArrayInputStream(yamlString.getBytes(StandardCharsets.UTF_8))); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /plugin/src/main/java/hudson/plugins/swarm/PluginImpl.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import hudson.Functions; 4 | import hudson.Plugin; 5 | import hudson.Util; 6 | import hudson.model.Computer; 7 | import hudson.model.Descriptor.FormException; 8 | import hudson.model.Node; 9 | import hudson.slaves.EnvironmentVariablesNodeProperty; 10 | import hudson.slaves.NodeProperty; 11 | import hudson.tools.ToolDescriptor; 12 | import hudson.tools.ToolInstallation; 13 | import hudson.tools.ToolLocationNodeProperty; 14 | import hudson.tools.ToolLocationNodeProperty.ToolLocation; 15 | import jakarta.servlet.http.HttpServletResponse; 16 | import java.io.IOException; 17 | import java.io.OutputStream; 18 | import java.io.Writer; 19 | import java.util.ArrayList; 20 | import java.util.LinkedHashSet; 21 | import java.util.List; 22 | import java.util.Properties; 23 | import java.util.Set; 24 | import jenkins.model.Jenkins; 25 | import jenkins.slaves.JnlpAgentReceiver; 26 | import org.apache.commons.lang.ArrayUtils; 27 | import org.kohsuke.stapler.QueryParameter; 28 | import org.kohsuke.stapler.StaplerRequest2; 29 | import org.kohsuke.stapler.StaplerResponse2; 30 | import org.kohsuke.stapler.verb.POST; 31 | 32 | /** 33 | * Exposes an entry point to add a new Swarm agent. 34 | * 35 | * @author Kohsuke Kawaguchi 36 | */ 37 | public class PluginImpl extends Plugin { 38 | 39 | private Node getNodeByName(String name, StaplerResponse2 rsp) throws IOException { 40 | Jenkins jenkins = Jenkins.get(); 41 | Node node = jenkins.getNode(name); 42 | 43 | if (node == null) { 44 | rsp.setStatus(HttpServletResponse.SC_NOT_FOUND); 45 | rsp.setContentType("text/plain; UTF-8"); 46 | rsp.getWriter().printf("Agent \"%s\" does not exist.%n", name); 47 | return null; 48 | } 49 | 50 | return node; 51 | } 52 | 53 | /** Get the list of labels for an agent. */ 54 | @SuppressWarnings({"lgtm[jenkins/csrf]", "lgtm[jenkins/no-permission-check]"}) 55 | public void doGetSlaveLabels(StaplerRequest2 req, StaplerResponse2 rsp, @QueryParameter String name) 56 | throws IOException { 57 | Node node = getNodeByName(name, rsp); 58 | if (node == null) { 59 | return; 60 | } 61 | 62 | normalResponse(req, rsp, node.getLabelString()); 63 | } 64 | 65 | private void normalResponse(StaplerRequest2 req, StaplerResponse2 rsp, String sLabelList) throws IOException { 66 | rsp.setContentType("text/xml"); 67 | 68 | try (Writer writer = rsp.getWriter()) { 69 | writer.write("" + sLabelList + ""); 70 | } 71 | } 72 | 73 | /** Add labels to an agent. */ 74 | @POST 75 | public void doAddSlaveLabels( 76 | StaplerRequest2 req, StaplerResponse2 rsp, @QueryParameter String name, @QueryParameter String labels) 77 | throws IOException { 78 | Node node = getNodeByName(name, rsp); 79 | if (node == null) { 80 | return; 81 | } 82 | 83 | node.checkPermission(Computer.CONFIGURE); 84 | 85 | LinkedHashSet currentLabels = stringToSet(node.getLabelString()); 86 | LinkedHashSet labelsToAdd = stringToSet(labels); 87 | currentLabels.addAll(labelsToAdd); 88 | node.setLabelString(setToString(currentLabels)); 89 | 90 | normalResponse(req, rsp, node.getLabelString()); 91 | } 92 | 93 | private static String setToString(Set labels) { 94 | return String.join(" ", labels); 95 | } 96 | 97 | private static LinkedHashSet stringToSet(String labels) { 98 | return new LinkedHashSet<>(List.of(labels.split("\\s+"))); 99 | } 100 | 101 | /** Remove labels from an agent. */ 102 | @POST 103 | public void doRemoveSlaveLabels( 104 | StaplerRequest2 req, StaplerResponse2 rsp, @QueryParameter String name, @QueryParameter String labels) 105 | throws IOException { 106 | Node node = getNodeByName(name, rsp); 107 | if (node == null) { 108 | return; 109 | } 110 | 111 | node.checkPermission(Computer.CONFIGURE); 112 | 113 | LinkedHashSet currentLabels = stringToSet(node.getLabelString()); 114 | LinkedHashSet labelsToRemove = stringToSet(labels); 115 | currentLabels.removeAll(labelsToRemove); 116 | node.setLabelString(setToString(currentLabels)); 117 | 118 | normalResponse(req, rsp, node.getLabelString()); 119 | } 120 | 121 | /** Add a new Swarm agent. */ 122 | @POST 123 | public void doCreateSlave( 124 | StaplerRequest2 req, 125 | StaplerResponse2 rsp, 126 | @QueryParameter String name, 127 | @QueryParameter(fixEmpty = true) String description, 128 | @QueryParameter int executors, 129 | @QueryParameter String remoteFsRoot, 130 | @QueryParameter String labels, 131 | @QueryParameter Node.Mode mode, 132 | @QueryParameter(fixEmpty = true) String hash, 133 | @QueryParameter boolean deleteExistingClients, 134 | @QueryParameter boolean keepDisconnectedClients) 135 | throws IOException { 136 | Jenkins jenkins = Jenkins.get(); 137 | 138 | jenkins.checkPermission(Computer.CREATE); 139 | jenkins.checkPermission(Computer.CONNECT); 140 | 141 | List> nodeProperties = new ArrayList<>(); 142 | 143 | String[] toolLocations = req.getParameterValues("toolLocation"); 144 | if (!ArrayUtils.isEmpty(toolLocations)) { 145 | List parsedToolLocations = parseToolLocations(toolLocations); 146 | nodeProperties.add(new ToolLocationNodeProperty(parsedToolLocations)); 147 | } 148 | 149 | String[] environmentVariables = req.getParameterValues("environmentVariable"); 150 | if (!ArrayUtils.isEmpty(environmentVariables)) { 151 | List parsedEnvironmentVariables = 152 | parseEnvironmentVariables(environmentVariables); 153 | nodeProperties.add(new EnvironmentVariablesNodeProperty(parsedEnvironmentVariables)); 154 | } 155 | 156 | // We use the existance of the node property itself as the boolean flag 157 | if (keepDisconnectedClients) { 158 | nodeProperties.add(new KeepSwarmClientNodeProperty()); 159 | } 160 | 161 | if (hash == null && jenkins.getNode(name) != null && !deleteExistingClients) { 162 | /* 163 | * This is a legacy client. They won't be able to pick up the new name, so throw them 164 | * away. Perhaps they can find another controller to connect to. 165 | */ 166 | rsp.setStatus(HttpServletResponse.SC_CONFLICT); 167 | rsp.setContentType("text/plain; UTF-8"); 168 | rsp.getWriter().printf("Agent \"%s\" already exists.%n", name); 169 | return; 170 | } 171 | 172 | if (hash != null) { 173 | /* 174 | * Try to make the name unique. Swarm clients are often replicated VMs, and they may 175 | * have the same name. 176 | */ 177 | name = name + '-' + hash; 178 | } 179 | 180 | // Check for existing connections. 181 | Node node = jenkins.getNode(name); 182 | if (node != null && !deleteExistingClients) { 183 | Computer computer = node.toComputer(); 184 | if (computer != null && computer.isOnline()) { 185 | /* 186 | * This is an existing connection. We'll only cause issues if we trample over an 187 | * online connection. 188 | */ 189 | rsp.setStatus(HttpServletResponse.SC_CONFLICT); 190 | rsp.setContentType("text/plain; UTF-8"); 191 | rsp.getWriter().printf("Agent \"%s\" is already created and on-line.%n", name); 192 | return; 193 | } 194 | } 195 | 196 | try { 197 | String nodeDescription = "Swarm agent from " + req.getRemoteHost(); 198 | if (description != null) { 199 | nodeDescription += ": " + description; 200 | } 201 | SwarmSlave agent = new SwarmSlave( 202 | name, 203 | nodeDescription, 204 | remoteFsRoot, 205 | String.valueOf(executors), 206 | mode, 207 | "swarm " + Util.fixNull(labels), 208 | nodeProperties); 209 | jenkins.addNode(agent); 210 | 211 | rsp.setContentType("text/plain; charset=iso-8859-1"); 212 | try (OutputStream outputStream = rsp.getOutputStream()) { 213 | Properties props = new Properties(); 214 | props.put("name", name); 215 | props.put("secret", JnlpAgentReceiver.SLAVE_SECRET.mac(name)); 216 | props.store(outputStream, ""); 217 | } 218 | } catch (FormException e) { 219 | Functions.printStackTrace(e, System.err); 220 | } 221 | } 222 | 223 | private static List parseToolLocations(String[] toolLocations) { 224 | List result = new ArrayList<>(); 225 | 226 | for (String toolLocKeyValue : toolLocations) { 227 | boolean found = false; 228 | /* 229 | * Limit the split on only the first occurrence of ':' so that the tool location path 230 | * can contain ':' characters. 231 | */ 232 | String[] toolLoc = toolLocKeyValue.split(":", 2); 233 | 234 | for (ToolDescriptor desc : ToolInstallation.all()) { 235 | for (ToolInstallation inst : desc.getInstallations()) { 236 | if (inst.getName().equals(toolLoc[0])) { 237 | found = true; 238 | 239 | String location = toolLoc[1]; 240 | 241 | ToolLocationNodeProperty.ToolLocation toolLocation = 242 | new ToolLocationNodeProperty.ToolLocation(desc, inst.getName(), location); 243 | result.add(toolLocation); 244 | } 245 | } 246 | } 247 | 248 | // Don't fail silently; rather, inform the user what tool is missing. 249 | if (!found) { 250 | throw new RuntimeException("No tool '" + toolLoc[0] + "' is defined on Jenkins."); 251 | } 252 | } 253 | 254 | return result; 255 | } 256 | 257 | private static List parseEnvironmentVariables( 258 | String[] environmentVariables) { 259 | List result = new ArrayList<>(); 260 | 261 | for (String environmentVariable : environmentVariables) { 262 | /* 263 | * Limit the split on only the first occurrence of ':' so that the value can contain ':' 264 | * characters. 265 | */ 266 | String[] keyValue = environmentVariable.split(":", 2); 267 | EnvironmentVariablesNodeProperty.Entry var = 268 | new EnvironmentVariablesNodeProperty.Entry(keyValue[0], keyValue[1]); 269 | result.add(var); 270 | } 271 | 272 | return result; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/LabelFileWatcher.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 4 | import java.io.File; 5 | import java.io.IOException; 6 | import java.io.InputStream; 7 | import java.net.HttpURLConnection; 8 | import java.net.URI; 9 | import java.net.URISyntaxException; 10 | import java.net.URL; 11 | import java.net.http.HttpClient; 12 | import java.net.http.HttpRequest; 13 | import java.net.http.HttpResponse; 14 | import java.nio.charset.StandardCharsets; 15 | import java.nio.file.Files; 16 | import java.nio.file.Paths; 17 | import java.util.ArrayList; 18 | import java.util.Arrays; 19 | import java.util.Collections; 20 | import java.util.List; 21 | import java.util.concurrent.TimeUnit; 22 | import java.util.logging.Level; 23 | import java.util.logging.Logger; 24 | import org.w3c.dom.Document; 25 | import org.xml.sax.SAXException; 26 | 27 | public class LabelFileWatcher implements Runnable { 28 | 29 | private static final Logger logger = Logger.getLogger(LabelFileWatcher.class.getName()); 30 | 31 | private static final long LABEL_FILE_WATCHER_INTERVAL_MILLIS = Long.getLong( 32 | LabelFileWatcher.class.getName() + ".labelFileWatcherIntervalMillis", TimeUnit.SECONDS.toMillis(30)); 33 | 34 | private boolean isRunning = false; 35 | private final Options options; 36 | private final String name; 37 | private String labels; 38 | private final String[] args; 39 | private final URL url; 40 | 41 | public LabelFileWatcher(URL url, Options options, String name, String... args) throws IOException { 42 | logger.config("LabelFileWatcher() constructed with: " + options.labelsFile + " and " + String.join(", ", args)); 43 | this.url = url; 44 | this.options = options; 45 | this.name = name; 46 | this.labels = Files.readString(Paths.get(options.labelsFile), StandardCharsets.UTF_8); 47 | this.args = args; 48 | logger.config("Labels loaded: " + labels); 49 | } 50 | 51 | private void softLabelUpdate(String sNewLabels) throws SoftLabelUpdateException { 52 | // 1. get labels from controller 53 | // 2. issue remove command for all old labels 54 | // 3. issue update commands for new labels 55 | logger.log( 56 | Level.CONFIG, 57 | "NOTICE: " + options.labelsFile + " has changed. Attempting soft label update (no node restart)"); 58 | 59 | logger.log(Level.CONFIG, "Getting current labels from controller"); 60 | 61 | Document xml; 62 | 63 | HttpClient client = SwarmClient.createHttpClient(options); 64 | HttpRequest.Builder builder = HttpRequest.newBuilder( 65 | URI.create(url + "plugin/swarm/getSlaveLabels?name=" + name)) 66 | .GET(); 67 | SwarmClient.addAuthorizationHeader(builder, options); 68 | HttpRequest request = builder.build(); 69 | try { 70 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); 71 | if (response.statusCode() != HttpURLConnection.HTTP_OK) { 72 | logger.log( 73 | Level.CONFIG, 74 | "Failed to retrieve labels from controller -- Response code: " + response.statusCode()); 75 | throw new SoftLabelUpdateException( 76 | "Unable to acquire labels from controller to begin removal process."); 77 | } 78 | try { 79 | xml = XmlUtils.parse(response.body()); 80 | } catch (SAXException e) { 81 | String msg = "Invalid XML received from " + url; 82 | logger.log(Level.SEVERE, msg, e); 83 | throw new SoftLabelUpdateException(msg); 84 | } 85 | } catch (IOException | InterruptedException e) { 86 | String msg = "Exception when reading from " + url; 87 | logger.log(Level.SEVERE, msg, e); 88 | throw new SoftLabelUpdateException(msg); 89 | } 90 | 91 | String labelStr = SwarmClient.getChildElementString(xml.getDocumentElement(), "labels"); 92 | labelStr = labelStr.replace("swarm", ""); 93 | 94 | logger.log(Level.CONFIG, "Labels to be removed: " + labelStr); 95 | 96 | // remove the labels in 1000 char blocks 97 | List lLabels = List.of(labelStr.split("\\s+")); 98 | StringBuilder sb = new StringBuilder(); 99 | for (String s : lLabels) { 100 | sb.append(s); 101 | sb.append(" "); 102 | if (sb.length() > 1000) { 103 | try { 104 | SwarmClient.postLabelRemove(name, sb.toString(), client, options, url); 105 | } catch (IOException | InterruptedException | RetryException e) { 106 | String msg = "Exception when removing label from " + url; 107 | logger.log(Level.SEVERE, msg, e); 108 | throw new SoftLabelUpdateException(msg); 109 | } 110 | sb = new StringBuilder(); 111 | } 112 | } 113 | if (sb.length() > 0) { 114 | try { 115 | SwarmClient.postLabelRemove(name, sb.toString(), client, options, url); 116 | } catch (IOException | InterruptedException | RetryException e) { 117 | String msg = "Exception when removing label from " + url; 118 | logger.log(Level.SEVERE, msg, e); 119 | throw new SoftLabelUpdateException(msg); 120 | } 121 | } 122 | 123 | // now add the labels back on 124 | logger.log(Level.CONFIG, "Labels to be added: " + sNewLabels); 125 | lLabels = List.of(sNewLabels.split("\\s+")); 126 | sb = new StringBuilder(); 127 | for (String s : lLabels) { 128 | sb.append(s); 129 | sb.append(" "); 130 | if (sb.length() > 1000) { 131 | try { 132 | SwarmClient.postLabelAppend(name, sb.toString(), client, options, url); 133 | } catch (IOException | InterruptedException | RetryException e) { 134 | String msg = "Exception when appending label to " + url; 135 | logger.log(Level.SEVERE, msg, e); 136 | throw new SoftLabelUpdateException(msg); 137 | } 138 | sb = new StringBuilder(); 139 | } 140 | } 141 | 142 | if (sb.length() > 0) { 143 | try { 144 | SwarmClient.postLabelAppend(name, sb.toString(), client, options, url); 145 | } catch (IOException | InterruptedException | RetryException e) { 146 | String msg = "Exception when appending label to " + url; 147 | logger.log(Level.SEVERE, msg, e); 148 | throw new SoftLabelUpdateException(msg); 149 | } 150 | } 151 | } 152 | 153 | private void hardLabelUpdate() throws IOException { 154 | logger.config("NOTICE: " + options.labelsFile + " has changed. Hard node restart attempt initiated."); 155 | isRunning = false; 156 | String javaBin = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"; 157 | try { 158 | File currentJar = new File(LabelFileWatcher.class 159 | .getProtectionDomain() 160 | .getCodeSource() 161 | .getLocation() 162 | .toURI()); 163 | if (!currentJar.getName().endsWith(".jar")) { 164 | throw new URISyntaxException(currentJar.getName(), "Doesn't end in .jar"); 165 | } else { 166 | // invoke the restart 167 | ArrayList command = new ArrayList<>(); 168 | command.add(javaBin); 169 | if (System.getProperty("java.util.logging.config.file") == null) { 170 | logger.warning("NOTE: You do not have a -Djava.util.logging.config.file specified," 171 | + " but your labels file has changed. You will lose logging for" 172 | + " the new client instance. Although the client will continue to" 173 | + " work, you will have no logging."); 174 | } else { 175 | command.add( 176 | "-Djava.util.logging.config.file=" + System.getProperty("java.util.logging.config.file")); 177 | } 178 | command.add("-jar"); 179 | command.add(currentJar.getPath()); 180 | Collections.addAll(command, args); 181 | String sCommandString = Arrays.toString(command.toArray()); 182 | sCommandString = 183 | sCommandString.replaceAll("\n", "").replaceAll("\r", "").replaceAll(",", ""); 184 | logger.config("Invoking: " + sCommandString); 185 | ProcessBuilder builder = new ProcessBuilder(command); 186 | builder.start(); 187 | logger.config("New node instance started, ignore subsequent warning."); 188 | } 189 | } catch (URISyntaxException e) { 190 | logger.log( 191 | Level.SEVERE, 192 | "ERROR: LabelFileWatcher unable to determine current running jar. Node" 193 | + " failure. URISyntaxException.", 194 | e); 195 | } 196 | } 197 | 198 | @Override 199 | @SuppressFBWarnings(value = "DM_EXIT", justification = "behavior is intentional") 200 | @SuppressWarnings("lgtm[jenkins/unsafe-calls]") 201 | public void run() { 202 | String sTempLabels; 203 | isRunning = true; 204 | 205 | logger.config("LabelFileWatcher running, monitoring file: " + options.labelsFile); 206 | 207 | while (isRunning) { 208 | try { 209 | logger.log( 210 | Level.FINE, 211 | String.format("LabelFileWatcher sleeping %d milliseconds", LABEL_FILE_WATCHER_INTERVAL_MILLIS)); 212 | Thread.sleep(LABEL_FILE_WATCHER_INTERVAL_MILLIS); 213 | } catch (InterruptedException e) { 214 | logger.log(Level.WARNING, "LabelFileWatcher InterruptedException occurred.", e); 215 | } 216 | try { 217 | sTempLabels = Files.readString(Paths.get(options.labelsFile), StandardCharsets.UTF_8); 218 | if (sTempLabels.equalsIgnoreCase(labels)) { 219 | logger.log(Level.FINEST, "Nothing to do. " + options.labelsFile + " has not changed."); 220 | } else { 221 | try { 222 | // try to do the "soft" form of label updating (manipulating the labels 223 | // through the plugin APIs 224 | softLabelUpdate(sTempLabels); 225 | labels = Files.readString(Paths.get(options.labelsFile), StandardCharsets.UTF_8); 226 | } catch (SoftLabelUpdateException e) { 227 | // if we're unable to 228 | logger.log( 229 | Level.WARNING, 230 | "WARNING: Normal process, soft label update failed. " 231 | + e.getLocalizedMessage() 232 | + ", forcing Swarm client restart. This can be disruptive" 233 | + " to Jenkins jobs. Check your Swarm client log files to" 234 | + " see why this is happening."); 235 | hardLabelUpdate(); 236 | } 237 | } 238 | } catch (IOException e) { 239 | logger.log( 240 | Level.WARNING, 241 | "WARNING: unable to read " 242 | + options.labelsFile 243 | + ", node may not be reporting proper labels to controller.", 244 | e); 245 | } 246 | } 247 | 248 | logger.warning("LabelFileWatcher no longer running. Shutting down this Swarm client."); 249 | System.exit(0); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /CHANGELOG.adoc: -------------------------------------------------------------------------------- 1 | = Changelog 2 | 3 | * All notable changes prior to 3.18 are documented in this changelog. 4 | * Release notes for versions >=3.18 can be found on the https://github.com/jenkinsci/swarm-plugin/releases[GitHub releases page]. 5 | 6 | == Release History 7 | 8 | === Version 3.17 (June 2, 2019) 9 | 10 | * Swarm plugin 11 | ** Add integration test framework (https://github.com/jenkinsci/swarm-plugin/pull/81[#81]) 12 | ** Add test for https://issues.jenkins.io/browse/JENKINS-39443[JENKINS-39443] (https://github.com/jenkinsci/swarm-plugin/pull/100[#100]) 13 | ** Miscellaneous code cleanup (https://github.com/jenkinsci/swarm-plugin/pull/86[#86], https://github.com/jenkinsci/swarm-plugin/pull/91[#91], https://github.com/jenkinsci/swarm-plugin/pull/92[#92], https://github.com/jenkinsci/swarm-plugin/pull/93[#93], https://github.com/jenkinsci/swarm-plugin/pull/94[#94], https://github.com/jenkinsci/swarm-plugin/pull/97[#97], https://github.com/jenkinsci/swarm-plugin/pull/101[#101], https://github.com/jenkinsci/swarm-plugin/pull/108[#108], https://github.com/jenkinsci/swarm-plugin/pull/109[#109]) 14 | * Swarm client 15 | ** Fix https://jenkins.io/security/advisory/2019-04-30/#SECURITY-1252[SECURITY-1252] XML External Entity (XXE) vulnerability (https://github.com/jenkinsci/swarm-plugin/pull/84[#84], https://github.com/jenkinsci/swarm-plugin/pull/90[#90], https://github.com/jenkinsci/swarm-plugin/pull/96[#96]) 16 | ** Rework logging of command-line arguments (https://github.com/jenkinsci/swarm-plugin/pull/95[#95]) 17 | ** Fix https://issues.jenkins.io/browse/JENKINS-42930[JENKINS-42930] Use System proxy when available and migrate from https://hc.apache.org/httpclient-3.x/[Commons HttpClient 3.x] to https://hc.apache.org/httpcomponents-client-ga/index.html[HttpComponents Client 4.x] (https://github.com/jenkinsci/swarm-plugin/pull/105[#105], link:docs/proxy.adoc[Documentation]) 18 | ** Fix https://issues.jenkins.io/browse/JENKINS-45295[JENKINS-45295] Swarm client should update labels on the fly when labels file changes (https://github.com/jenkinsci/swarm-plugin/pull/104[#104], https://github.com/jenkinsci/swarm-plugin/pull/110[#110]) 19 | ** Fix https://issues.jenkins.io/browse/JENKINS-50970[JENKINS-50970] SLF4J logging not working in Swarm client (https://github.com/jenkinsci/swarm-plugin/pull/98[#98], https://github.com/jenkinsci/swarm-plugin/pull/99[#99], https://github.com/jenkinsci/swarm-plugin/pull/102[#102], https://github.com/jenkinsci/swarm-plugin/pull/106[#106], link:docs/logging.adoc[Documentation]) 20 | ** Fix invalid exit code (https://github.com/jenkinsci/swarm-plugin/pull/103[#103]) 21 | ** Remove deprecated `-logFile` parameter (https://github.com/jenkinsci/swarm-plugin/pull/107[#107]) 22 | 23 | === Version 3.16 (May 21, 2019) 24 | 25 | * Swarm plugin 26 | ** Update minimum Jenkins core requirement to 2.60.3 (https://github.com/jenkinsci/swarm-plugin/pull/87[#87]) 27 | ** Remove unnecessary field (https://github.com/jenkinsci/swarm-plugin/pull/85[#85]) 28 | * Swarm client 29 | ** Disable DTDs completely in all XML parsers to prevent XML External Entity (XXE) attacks (https://github.com/jenkinsci/swarm-plugin/pull/84[#84]) 30 | ** Update Jenkins Remoting version from 3.28 to 3.30 (https://github.com/jenkinsci/swarm-plugin/pull/78[#78], https://github.com/jenkinsci/remoting/blob/master/CHANGELOG.md[full changelog]) 31 | 32 | === Version 3.15 (December 12, 2018) 33 | 34 | * Swarm plugin 35 | ** Fix the label removal in the `removeLabels` endpoint (https://github.com/jenkinsci/swarm-plugin/pull/75[#75]) 36 | * Swarm client 37 | ** Update Jenkins Remoting version from 3.26 to 3.28 to pick up new stability fixes (https://github.com/jenkinsci/remoting/blob/master/CHANGELOG.md[full changelog]) 38 | 39 | === Version 3.14 (September 4, 2018) 40 | 41 | * Swarm client 42 | ** Update Remoting from 3.21 to 3.26 in order to pick up new stability fixes (https://github.com/jenkinsci/remoting/blob/master/CHANGELOG.md[full changelog]) 43 | * Swarm plugin 44 | ** Update Swarm client to 3.26 45 | 46 | === Version 3.13 (June 8, 2018) 47 | 48 | * Swarm plugin 49 | ** Update minimal Jenkins Core requirement to 2.60.1 50 | * Swarm client 51 | ** Update Remoting to 3.21 to pick up logging and `no_proxy` handling fixes (https://github.com/jenkinsci/remoting/blob/master/CHANGELOG.md#321[full changelog]) 52 | 53 | === Version 3.12 (March 22, 2018) 54 | 55 | * Swarm client 56 | ** https://issues.jenkins.io/browse/JENKINS-50237[JENKINS-50237] Update Remoting from 3.18 to 3.19 to pick up the exception propagation fix (https://github.com/jenkinsci/remoting/blob/master/CHANGELOG.md#319[full changelog]) 57 | 58 | === Version 3.11 (March 19, 2018) 59 | 60 | * Swarm client 61 | ** https://issues.jenkins.io/browse/JENKINS-50252[JENKINS-50252] Update Remoting from 3.16 to 3.18 to pick up bug fixes and serialization diagnosability improvements (https://github.com/jenkinsci/remoting/blob/master/CHANGELOG.md[full changelog]) 62 | ** https://github.com/jenkinsci/swarm-plugin/pull/68[#68] The plugin now trims input strings for password files specified in `-passwordFile` 63 | 64 | === Version 3.10 (February 21, 2018) 65 | 66 | * Swarm plugin 67 | ** https://github.com/jenkinsci/swarm-plugin/pull/62[#62] Add ability to download the client directory from the plugin installed in Jenkins via `${JENKINS_URL}/swarm/swarm-client.jar` 68 | 69 | === Version 3.9 (February 7, 2018) 70 | 71 | * Swarm plugin 72 | ** https://issues.jenkins.io/browse/JENKINS-49292[JENKINS-49292] Reduce log level from ALL to INFO in sample logging.properties to reduce log spam 73 | * Swarm client 74 | ** https://github.com/jenkinsci/swarm-plugin/pull/66[#66] Add support of the `-passwordFile` option 75 | 76 | === Version 3.8 (January 10, 2018) 77 | 78 | * Swarm client 79 | ** https://github.com/jenkinsci/swarm-plugin/pull/65[#65] Update Remoting from 3.15 to 3.16 (https://github.com/jenkinsci/remoting/blob/master/CHANGELOG.md[full changelog]) 80 | 81 | === Version 3.7 (December 22, 2017) 82 | 83 | * Swarm client 84 | ** https://github.com/jenkinsci/swarm-plugin/pull/63[#63] Require Java 8 (client-side only) 85 | ** https://github.com/jenkinsci/swarm-plugin/pull/63[#63] Update Remoting from 3.10.2 to 3.15 (https://github.com/jenkinsci/remoting/blob/master/CHANGELOG.md[full changelog]) 86 | ** https://github.com/jenkinsci/swarm-plugin/pull/61[#61] Prevent the infinite reconnect cycle in Remoting Launcher, use the client's failover logic instead 87 | 88 | === Version 3.6 (October 18, 2017) 89 | 90 | * Update Remoting in Swarm client from 3.4.1 to 3.10.2 (https://github.com/jenkinsci/remoting/blob/master/CHANGELOG.md[full changelog]) 91 | * https://github.com/jenkinsci/swarm-plugin/pull/55[#55] Introduce the `-pidFile` option, which creates a file with the process PID 92 | ** Errata: The current implementation may cause file descriptor leaks in edge cases 93 | * https://issues.jenkins.io/browse/JENKINS-43674[JENKINS-43674] Prevent `NullPointerException` in Swarm client in HTTPS mode without `-disableSslVerification` or `-sslFingerprints` 94 | * https://issues.jenkins.io/browse/JENKINS-42098[JENKINS-42098] Prevent `LinkageError` when building a Maven project on a Swarm node with new Maven versions 95 | 96 | === Version 3.5 (October 11, 2017) 97 | 98 | * https://jenkins.io/security/advisory/2017-10-11/#swarm-plugin-client-bundled-vulnerable-version-of-the-commons-httpclient-library[SECURITY-597] Swarm client bundled version of the commons-httpclient library, which was vulnerable to MitM 99 | 100 | === Version 3.4 (April 10, 2017) 101 | 102 | * Add option `-sslFingerprints` providing a possibility to add custom SSL trust anchors without adding them to the system store 103 | 104 | === Version 3.3 (February 10, 2017) 105 | 106 | * Finally a release! 107 | * Add `-logFile` and `-labelsFile` options. Now supports dynamic labels. 108 | * Add support for very large numbers of dynamic labels when using `-labelsFile` 109 | * Remove consecutive slashes in plugin URLs 110 | * Docker Compose configuration updates 111 | * Add retry backoff strategy 112 | * Bump Remoting library to same as Jenkins LTS at the moment 113 | * Updates to make build and testing pass with new Jenkins plugin parent POM work 114 | 115 | === Version 3.2 (February 8, 2017) 116 | 117 | * Failed to release due to INFRA-588 118 | 119 | === Version 3.1 (February 8, 2017) 120 | 121 | * Failed to release due to INFRA-588 122 | 123 | === Version 3.0 (December 27, 2016) 124 | 125 | * Failed to release due to INFRA-588 126 | 127 | === Version 2.3 (November 28, 2016) 128 | 129 | * Failed to release due to INFRA-588 130 | 131 | === Version 2.2 (July 26, 2016) 132 | 133 | * Failed to release due to INFRA-588 134 | 135 | === Version 2.1 (May 20, 2016) 136 | 137 | * Implement https://issues.jenkins.io/browse/JENKINS-28917[JENKINS-28917] Update remoting to one supported by latest LTS 138 | * `MESOS_TASK_ID` used as Jenkins slave ID if available as environment variable (for Mesos/Marathon integration) 139 | * Updating Jenkins remoting dependency. Swarm client now matches the Remoting version in Jenkins 1.625.3 LTS. 140 | * Implement https://issues.jenkins.io/browse/JENKINS-34593[JENKINS-34593] add an option to delete existing clients 141 | * Add integration test environment based upon Docker compose 142 | 143 | === Version 2.0 (August 3, 2015) 144 | 145 | * Implement https://issues.jenkins.io/browse/JENKINS-29232[JENKINS-28148] Whitespace in tool locations, (breaking change, see https://github.com/jenkinsci/swarm-plugin/pull/28[#28]) 146 | * Add ability to disable unique ID for clients (https://github.com/jenkinsci/swarm-plugin/pull/33[#33]) 147 | * Remove unused code and reformat source files 148 | 149 | === Version 1.26 (July 21, 2015) 150 | 151 | * Re-release of 1.25, some artifacts was not properly deployed 152 | 153 | === Version 1.25 (July 21, 2015) 154 | 155 | * Correct https://issues.jenkins.io/browse/JENKINS-29232[JENKINS-29232] Set the HTTP Connection:close header to ensure the underlying socket is closed (https://github.com/jenkinsci/swarm-plugin/pull/29[#29]) 156 | * Add a Markdown formatted README to better describe the project for GitHub viewers 157 | * Improve end user reporting of hostname lookup errors (https://github.com/jenkinsci/swarm-plugin/pull/30[#30]) 158 | * Made Javadoc compile with JDK 8 159 | 160 | === Version 1.24 (April 28, 2015) 161 | 162 | * Correct https://issues.jenkins.io/browse/JENKINS-26558[JENKINS-26558] Clients should provide a unique ID to be used for name collision avoidance (https://github.com/jenkinsci/swarm-plugin/pull/26[#26]) 163 | * Improve printout when Jenkins master is not configured with a URL (https://github.com/jenkinsci/swarm-plugin/pull/27[#27]) 164 | 165 | === Version 1.23 (April 27, 2015) 166 | 167 | * Add the tunnel option to pass it to the Jenkins engine (https://github.com/jenkinsci/swarm-plugin/pull/22[#22]) 168 | * Minor enhancements to make the Swarm client usable for mere detection of Jenkins instances (https://github.com/jenkinsci/swarm-plugin/pull/22[#23]) 169 | * Correct https://issues.jenkins.io/browse/JENKINS-24149[JENKINS-24149] `LogConfigurationException` (https://github.com/jenkinsci/swarm-plugin/pull/24[#24]) 170 | * `Computer.toNode()` can return `null` (https://github.com/jenkinsci/swarm-plugin/pull/25[#25]) 171 | 172 | === Version 1.22 (November 28, 2014) 173 | 174 | * Add new option `passwordEnvVariable` (https://github.com/jenkinsci/swarm-plugin/pull/21[#21]) 175 | 176 | === Version 1.21 (November 6, 2014) 177 | 178 | * Instead of constructing the tool location key, just use the existing descriptor (https://issues.jenkins.io/browse/JENKINS-25064[JENKINS-25064], see https://github.com/jenkinsci/swarm-plugin/pull/20[#20]) 179 | * Use latest Jenkins LTS Remoting library (1.580.1 Jenkins LTS version) 180 | 181 | === Version 1.20 (October 8, 2014) 182 | 183 | * Fix up handling of tool locations on Windows (https://issues.jenkins.io/browse/JENKINS-25002[JENKINS-25002], see https://github.com/jenkinsci/swarm-plugin/pull/19[#19]) 184 | 185 | === Version 1.19 (October 6, 2014) 186 | 187 | * Correct bug introduced by 1.18 where the client did not work _unless_ you set tool locations (https://issues.jenkins.io/browse/JENKINS-24995[JENKINS-24995], see https://github.com/jenkinsci/swarm-plugin/pull/18[#18]) 188 | 189 | === Version 1.18 (October 2, 2014) 190 | 191 | * Set tool locations from Swarm client, (https://issues.jenkins.io/browse/JENKINS-7543[JENKINS-7543], see https://github.com/jenkinsci/swarm-plugin/pull/17[#17]) 192 | 193 | === Version 1.17 (September 30, 2014) 194 | 195 | * Add `-noRetryAfterConnected` and `-retry` options. These provide optional exit strategies for the default unlimited retry loop 196 | * Require a well-formed master URL, ensuring trailing slash 197 | * https://issues.jenkins.io/browse/JENKINS-21892[JENKINS-21892] Update Swarm client to send CSRF token 198 | * Use latest releases of `commons-codec`, `args4j`, and Remoting 199 | 200 | === Version 1.16 (July 1, 2014) 201 | 202 | * Bump remoting to match Jenkins LTS (https://issues.jenkins.io/browse/JENKINS-22730[JENKINS-22730], see https://github.com/jenkinsci/swarm-plugin/pull/14[#14]) 203 | 204 | === Version 1.15 205 | 206 | * _undocumented, or maybe a typo of 1.12?_ 207 | 208 | === Version 1.12, 1.11 (January 15, 2014) 209 | 210 | * Use compatible version of `commons-codec` (https://issues.jenkins.io/browse/JENKINS-21155[JENKINS-21155], see https://github.com/jenkinsci/swarm-plugin/pull/7[#7] and https://github.com/jenkinsci/swarm-plugin/pull/8[#8]) 211 | 212 | === Version 1.10 (October 21, 2013) 213 | 214 | * Swarm 1.9 can't connect to current LTS as `slave.jar` too old (https://issues.jenkins.io/browse/JENKINS-20138[JENKINS-20138]) 215 | 216 | === Version 1.9 (May 18, 2013) 217 | 218 | * Add option for specifying `Node.Mode` (https://github.com/jenkinsci/swarm-plugin/pull/3[#3]) 219 | 220 | === Version 1.8 (November 21, 2012) 221 | 222 | * Changing broadcast to send a UDP packet payload of 128 bytes instead of 0 223 | * Allow slave connection without requiring UDP 224 | * Add `disableSslVerification` option 225 | 226 | === Version 1.6 (March 18, 2012) 227 | 228 | * Fix references from Hudson to Jenkins 229 | * Swarm client fails to connect to Jenkins when authentication is enabled but Authorization is disabled (https://issues.jenkins.io/browse/JENKINS-11663[JENKINS-11663]) 230 | * Support remoting 2.12 231 | 232 | === Version 1.5 (August 11, 2011) 233 | 234 | * Check whether user has `SlaveComputer.CREATE` permission 235 | * Allow authentication in Swarm plugin (https://issues.jenkins.io/browse/JENKINS-5504[JENKINS-5504]) 236 | 237 | === Version 1.4 (August 14, 2010) 238 | 239 | * Fix broken help links 240 | * Node properties save correctly 241 | * Add Japanese localization 242 | 243 | === Version 1.3 (January 14, 2010) 244 | 245 | * Fix a packaging problem in the client JAR (https://issues.jenkins.io/browse/JENKINS-5275[JENKINS-5275]) 246 | 247 | === Version 1.2 (December 30, 2009) 248 | 249 | * Minor text correction 250 | 251 | === Version 1.1 (July 15, 2009) 252 | 253 | * Add the `-master` option 254 | 255 | === Version 1.0 (May 23, 2009) 256 | 257 | * Initial release 258 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/Client.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import java.io.IOException; 4 | import java.io.InputStream; 5 | import java.io.UncheckedIOException; 6 | import java.net.InetAddress; 7 | import java.net.URL; 8 | import java.net.UnknownHostException; 9 | import java.nio.charset.StandardCharsets; 10 | import java.nio.file.Files; 11 | import java.nio.file.InvalidPathException; 12 | import java.nio.file.Path; 13 | import java.nio.file.Paths; 14 | import java.util.Optional; 15 | import java.util.logging.Level; 16 | import java.util.logging.Logger; 17 | import org.kohsuke.args4j.CmdLineException; 18 | import org.kohsuke.args4j.CmdLineParser; 19 | import org.kohsuke.args4j.NamedOptionDef; 20 | import org.kohsuke.args4j.spi.FieldSetter; 21 | import org.kohsuke.args4j.spi.OptionHandler; 22 | 23 | public class Client { 24 | 25 | private static final Logger logger = Logger.getLogger(Client.class.getName()); 26 | private static final String NON_FATAL_JNLP_AGENT_ENDPOINT_RESOLUTION_EXCEPTIONS = 27 | "hudson.remoting.Engine.nonFatalJnlpAgentEndpointResolutionExceptions"; 28 | 29 | // TODO: Cleanup the encoding issue 30 | @SuppressWarnings("lgtm[jenkins/unsafe-calls]") 31 | public static void main(String... args) throws InterruptedException { 32 | Options options = new Options(); 33 | CmdLineParser parser = new CmdLineParser(options); 34 | try { 35 | parser.parseArgument(args); 36 | } catch (CmdLineException e) { 37 | fail(e.getMessage()); 38 | } 39 | 40 | logArguments(parser); 41 | 42 | if (options.help) { 43 | parser.printUsage(System.out); 44 | System.exit(0); 45 | } 46 | 47 | if (options.config != null) { 48 | if (hasConflictingOptions(parser)) { 49 | fail("'-config' can not be used with other options."); 50 | } 51 | logger.log(Level.INFO, "Load configuration from {0}", options.config.getPath()); 52 | 53 | try (InputStream is = Files.newInputStream(options.config.toPath())) { 54 | options = new YamlConfig().loadOptions(is); 55 | } catch (InvalidPathException | IOException | ConfigurationException e) { 56 | fail(e.getMessage()); 57 | } 58 | } 59 | 60 | try { 61 | validateOptions(options); 62 | } catch (RuntimeException e) { 63 | fail(e.getMessage()); 64 | } 65 | 66 | // Pass the command line arguments along so that the LabelFileWatcher thread can have them. 67 | run(new SwarmClient(options), options, args); 68 | } 69 | 70 | private static boolean hasConflictingOptions(CmdLineParser parser) { 71 | return parser.getOptions().stream() 72 | .anyMatch(oh -> !getKey(oh).equals("-config") 73 | && !isDefaultOption(getKey(oh), getValue(oh), new CmdLineParser(new Options()))); 74 | } 75 | 76 | private static void validateOptions(Options options) { 77 | if (options.url == null) { 78 | throw new IllegalArgumentException("Missing 'url' option."); 79 | } 80 | if (options.pidFile != null) { 81 | ProcessHandle current = ProcessHandle.current(); 82 | Path pidFile = Paths.get(options.pidFile); 83 | if (Files.exists(pidFile)) { 84 | long oldPid; 85 | try { 86 | oldPid = Long.parseLong(Files.readString(pidFile, StandardCharsets.US_ASCII)); 87 | } catch (NumberFormatException e) { 88 | oldPid = 0; 89 | } catch (IOException e) { 90 | throw new UncheckedIOException("Failed to read PID file " + pidFile, e); 91 | } 92 | // check if this process is running 93 | if (oldPid > 0) { 94 | Optional oldProcess = ProcessHandle.of(oldPid); 95 | if (oldProcess.isPresent() && oldProcess.get().isAlive()) { 96 | // If the old process is running, then compare its path to the path of this 97 | // process as a quick sanity check. If they are the same, we can assume that 98 | // it is probably another Swarm Client instance, in which case the service 99 | // should not be started. However, if the previous Swarm Client instance 100 | // failed to exit cleanly (because of a crash/reboot/etc.) and another 101 | // process is now using that PID, then we should consider the PID file stale 102 | // and continue execution. 103 | String curCommand = current.info().command().orElse(null); 104 | String oldCommand = oldProcess.get().info().command().orElse(null); 105 | if (curCommand != null && curCommand.equals(oldCommand)) { 106 | throw new IllegalStateException(String.format( 107 | "Refusing to start because PID file '%s' already exists" 108 | + " and the previous process %d (%s) is still" 109 | + " running.", 110 | pidFile.toAbsolutePath(), 111 | oldPid, 112 | oldProcess.get().info().commandLine().orElse("unknown"))); 113 | } else { 114 | logger.warning(String.format( 115 | "Ignoring stale PID file '%s' because the process %d" 116 | + " (%s) is not a Swarm Client.", 117 | pidFile.toAbsolutePath(), 118 | oldPid, 119 | oldProcess.get().info().commandLine().orElse("unknown"))); 120 | } 121 | } else { 122 | logger.fine(String.format( 123 | "Ignoring PID file '%s' because the previous process %d is no longer running.", 124 | pidFile.toAbsolutePath(), oldPid)); 125 | } 126 | } 127 | } 128 | pidFile.toFile().deleteOnExit(); 129 | try { 130 | Files.writeString(pidFile, Long.toString(current.pid()), StandardCharsets.US_ASCII); 131 | } catch (IOException e) { 132 | throw new UncheckedIOException("Failed to write PID file " + options.pidFile, e); 133 | } 134 | } 135 | 136 | // Check to see if passwordEnvVariable is set, if so pull down the 137 | // password from the env and set as password. 138 | if (options.passwordEnvVariable != null) { 139 | options.password = System.getenv(options.passwordEnvVariable); 140 | } 141 | // read pass from file if no other password was specified 142 | if (options.password == null && options.passwordFile != null) { 143 | try { 144 | options.password = Files.readString(Paths.get(options.passwordFile), StandardCharsets.UTF_8) 145 | .trim(); 146 | } catch (IOException e) { 147 | throw new UncheckedIOException("Failed to read password from file", e); 148 | } 149 | } 150 | 151 | /* 152 | * Only look up the hostname if we have not already specified name of the agent. In certain 153 | * cases this lookup might fail (e.g., querying an external DNS server which might not be 154 | * informed of a newly created agent from a DHCP server). 155 | * 156 | * From https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/net/InetAddress.html#getCanonicalHostName() 157 | * 158 | * "Gets the fully qualified domain name for this IP address. Best effort method, meaning we 159 | * may not be able to return the FQDN depending on the underlying system configuration." 160 | */ 161 | if (options.name == null) { 162 | try { 163 | options.name = InetAddress.getLocalHost().getCanonicalHostName(); 164 | } catch (UnknownHostException e) { 165 | logger.severe("Failed to look up the canonical hostname of this agent. Check the system DNS settings."); 166 | logger.severe("If it is not possible to resolve this host, specify a name using the \"-name\" option."); 167 | throw new UncheckedIOException("Failed to set hostname", e); 168 | } 169 | } 170 | } 171 | 172 | /** 173 | * Run the Swarm client. 174 | * 175 | *

This method never returns. 176 | */ 177 | static void run(SwarmClient swarmClient, Options options, String... args) throws InterruptedException { 178 | logger.info("Connecting to Jenkins controller"); 179 | URL url = swarmClient.getUrl(); 180 | 181 | // wait until we get the ACK back 182 | int retry = 0; 183 | while (true) { 184 | try { 185 | logger.info("Attempting to connect to " + url); 186 | 187 | /* 188 | * Create a new Swarm agent. After this method returns, the value of the name field 189 | * has been set to the name returned by the server, which may or may not be the name 190 | * we originally requested. 191 | */ 192 | swarmClient.createSwarmAgent(url); 193 | 194 | /* 195 | * Set up the label file watcher thread. If the label file changes, this thread 196 | * takes action to restart the client. Note that this must be done after we create 197 | * the Swarm agent, since only then has the server returned the name we must use 198 | * when doing label operations. 199 | */ 200 | if (options.labelsFile != null) { 201 | logger.info("Setting up LabelFileWatcher"); 202 | LabelFileWatcher l = new LabelFileWatcher(url, options, swarmClient.getName(), args); 203 | Thread labelFileWatcherThread = new Thread(l, "LabelFileWatcher"); 204 | labelFileWatcherThread.setDaemon(true); 205 | labelFileWatcherThread.start(); 206 | } 207 | 208 | /* 209 | * Prevent Remoting from killing the process on JNLP agent endpoint resolution 210 | * exceptions. 211 | */ 212 | if (System.getProperty(NON_FATAL_JNLP_AGENT_ENDPOINT_RESOLUTION_EXCEPTIONS) == null) { 213 | System.setProperty(NON_FATAL_JNLP_AGENT_ENDPOINT_RESOLUTION_EXCEPTIONS, "true"); 214 | } 215 | 216 | /* 217 | * Note that any instances of InterruptedException or RuntimeException thrown 218 | * internally by the next line get wrapped in RetryException. 219 | */ 220 | swarmClient.connect(url); 221 | if (options.noRetryAfterConnected) { 222 | logger.warning("Connection closed, exiting..."); 223 | swarmClient.exitWithStatus(0); 224 | } 225 | } catch (IOException | InterruptedException | RetryException e) { 226 | logger.log(Level.SEVERE, "An error occurred", e); 227 | } 228 | 229 | int waitTime = 230 | options.retryBackOffStrategy.waitForRetry(retry++, options.retryInterval, options.maxRetryInterval); 231 | if (options.retry >= 0) { 232 | if (retry >= options.retry) { 233 | logger.severe("Retry limit reached, exiting..."); 234 | swarmClient.exitWithStatus(1); 235 | } else { 236 | logger.warning("Remaining retries: " + (options.retry - retry)); 237 | } 238 | } 239 | 240 | // retry 241 | logger.info("Retrying in " + waitTime + " seconds"); 242 | swarmClient.sleepSeconds(waitTime); 243 | } 244 | } 245 | 246 | private static void logArguments(CmdLineParser parser) { 247 | Options defaultOptions = new Options(); 248 | CmdLineParser defaultParser = new CmdLineParser(defaultOptions); 249 | 250 | StringBuilder sb = new StringBuilder("Client invoked with: "); 251 | for (OptionHandler argument : parser.getArguments()) { 252 | logValue(sb, argument, null); 253 | } 254 | for (OptionHandler option : parser.getOptions()) { 255 | logValue(sb, option, defaultParser); 256 | } 257 | logger.info(sb.toString()); 258 | } 259 | 260 | private static void logValue(StringBuilder sb, OptionHandler handler, CmdLineParser defaultParser) { 261 | String key = getKey(handler); 262 | Object value = getValue(handler); 263 | 264 | if (handler.option.help()) { 265 | return; 266 | } 267 | 268 | if (defaultParser != null && isDefaultOption(key, value, defaultParser)) { 269 | return; 270 | } 271 | 272 | sb.append(key); 273 | sb.append(' '); 274 | if (key.equals("-username") || key.startsWith("-password")) { 275 | sb.append("*****"); 276 | } else { 277 | sb.append(value); 278 | } 279 | sb.append(' '); 280 | } 281 | 282 | private static String getKey(OptionHandler optionHandler) { 283 | if (optionHandler.option instanceof NamedOptionDef) { 284 | NamedOptionDef namedOptionDef = (NamedOptionDef) optionHandler.option; 285 | return namedOptionDef.name(); 286 | } else { 287 | return optionHandler.option.toString(); 288 | } 289 | } 290 | 291 | private static Object getValue(OptionHandler optionHandler) { 292 | FieldSetter setter = optionHandler.setter.asFieldSetter(); 293 | return setter == null ? null : setter.getValue(); 294 | } 295 | 296 | private static boolean isDefaultOption(String key, Object value, CmdLineParser defaultParser) { 297 | for (OptionHandler defaultOption : defaultParser.getOptions()) { 298 | String defaultKey = getKey(defaultOption); 299 | if (defaultKey.equals(key)) { 300 | Object defaultValue = getValue(defaultOption); 301 | if (defaultValue == null && value == null) { 302 | return true; 303 | } 304 | return defaultValue != null && defaultValue.equals(value); 305 | } 306 | } 307 | return false; 308 | } 309 | 310 | @SuppressWarnings("lgtm[jenkins/unsafe-calls]") 311 | private static void fail(String message) { 312 | System.err.println(message); 313 | System.exit(1); 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /plugin/src/test/java/hudson/plugins/swarm/test/SwarmClientRule.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm.test; 2 | 3 | import static org.hamcrest.MatcherAssert.assertThat; 4 | import static org.hamcrest.Matchers.greaterThan; 5 | import static org.junit.Assert.assertEquals; 6 | import static org.junit.Assert.assertNotNull; 7 | import static org.junit.Assert.assertTrue; 8 | 9 | import hudson.model.Computer; 10 | import hudson.model.Node; 11 | import hudson.security.ProjectMatrixAuthorizationStrategy; 12 | import java.io.File; 13 | import java.io.IOException; 14 | import java.io.UncheckedIOException; 15 | import java.net.URI; 16 | import java.net.URISyntaxException; 17 | import java.net.URL; 18 | import java.nio.charset.StandardCharsets; 19 | import java.nio.file.Files; 20 | import java.nio.file.Path; 21 | import java.util.ArrayList; 22 | import java.util.Collections; 23 | import java.util.List; 24 | import java.util.concurrent.TimeUnit; 25 | import java.util.function.Function; 26 | import java.util.function.Supplier; 27 | import java.util.logging.Level; 28 | import java.util.logging.Logger; 29 | import org.apache.commons.io.FileUtils; 30 | import org.apache.commons.io.input.Tailer; 31 | import org.apache.commons.io.input.TailerListenerAdapter; 32 | import org.jenkinsci.plugins.matrixauth.AuthorizationMatrixNodeProperty; 33 | import org.jenkinsci.plugins.workflow.support.concurrent.Timeout; 34 | import org.junit.rules.ExternalResource; 35 | import org.junit.rules.TemporaryFolder; 36 | import org.jvnet.hudson.test.JenkinsRule; 37 | import org.jvnet.hudson.test.JenkinsSessionRule; 38 | 39 | /** 40 | * A rule for starting the Swarm client. The rule does nothing before running the test method. The 41 | * caller is expected to start the Swarm client using either {@link #createSwarmClient} or {@link 42 | * #createSwarmClientWithName}. Once the Swarm client has been started, the caller may explicitly 43 | * terminate it with {@link #tearDown()}. Only one instance of the Swarm client may be running at a 44 | * time. If an instance is running at the end of the test method, it will be automatically torn 45 | * down. Automatic tear down will also take place if the test times out. 46 | * 47 | *

Should work in combination with {@link JenkinsRule} or {@link JenkinsSessionRule}. 48 | */ 49 | public class SwarmClientRule extends ExternalResource { 50 | 51 | private static final Logger logger = Logger.getLogger(SwarmClientRule.class.getName()); 52 | 53 | /** A {@link Supplier} for compatibility with {@link JenkinsSessionRule}. */ 54 | final Supplier j; 55 | 56 | /** 57 | * Used for storing the Swarm Client JAR file as well as for the client's Remoting working 58 | * directory. 59 | */ 60 | private final TemporaryFolder temporaryFolder; 61 | 62 | /** Whether or not the client is currently active. */ 63 | private boolean isActive = false; 64 | 65 | /** The username to use when connecting to the Jenkins controller. */ 66 | String swarmUsername; 67 | 68 | /** The password or API token to use when connecting to the Jenkins controller. */ 69 | String swarmPassword; 70 | 71 | /** 72 | * The {@link Computer} object corresponding to the agent within Jenkins, if the client is 73 | * active. 74 | */ 75 | private Computer computer; 76 | 77 | /** A {@link Tailer} for watching the client's standard out stream, if the client is active. */ 78 | private Tailer stdoutTailer; 79 | 80 | /** 81 | * A {@link Thread} for running the {@link Tailer} for the client's standard out stream, if the 82 | * client is active. 83 | */ 84 | private Thread stdoutThread; 85 | 86 | /** 87 | * A {@link Tailer} for watching the client's standard error stream, if the client is active. 88 | */ 89 | private Tailer stderrTailer; 90 | 91 | /** 92 | * A {@link Thread} for running the {@link Tailer} for the client's standard error stream, if 93 | * the client is active. 94 | */ 95 | private Thread stderrThread; 96 | 97 | /** The {@link Process} corresponding to the client, if the client is active. */ 98 | private Process process; 99 | 100 | public SwarmClientRule(Supplier j, TemporaryFolder temporaryFolder) { 101 | this.j = j; 102 | this.temporaryFolder = temporaryFolder; 103 | } 104 | 105 | public GlobalSecurityConfigurationBuilder globalSecurityConfigurationBuilder() { 106 | return new GlobalSecurityConfigurationBuilder(this); 107 | } 108 | 109 | /** 110 | * Create a new Swarm agent on the local host and wait for it to come online before returning. 111 | * The agent will be named automatically. 112 | */ 113 | public synchronized Node createSwarmClient(String... args) throws InterruptedException, IOException { 114 | if (isActive) { 115 | throw new IllegalStateException( 116 | "You must first tear down the existing Swarm client with \"tearDown()\" before" 117 | + " creating a new one."); 118 | } 119 | 120 | String agentName = "agent" + j.get().jenkins.getNodes().size(); 121 | return createSwarmClientWithName(agentName, args); 122 | } 123 | 124 | /** 125 | * Create a new Swarm agent on the local host and wait for it to come online before returning. 126 | * 127 | * @param agentName The proposed name of the agent. 128 | * @param args The arguments to pass to the client. 129 | * @return The online node 130 | */ 131 | public synchronized Node createSwarmClientWithName(String agentName, String... args) 132 | throws InterruptedException, IOException { 133 | // Create the password file. 134 | Path passwordFile = null; 135 | if (swarmPassword != null) { 136 | passwordFile = Files.createTempFile(temporaryFolder.getRoot().toPath(), "password", null); 137 | Files.write(passwordFile, swarmPassword.getBytes(StandardCharsets.UTF_8)); 138 | } 139 | 140 | final String passwordFilePath = passwordFile != null ? passwordFile.toString() : null; 141 | return createSwarmClientWithCommand(agentName, swarmClientJar -> { 142 | try { 143 | return getCommand(swarmClientJar, j.get().getURL(), agentName, swarmUsername, passwordFilePath, args); 144 | } catch (IOException e) { 145 | throw new UncheckedIOException(e); 146 | } 147 | }); 148 | } 149 | 150 | /** 151 | * Create a new Swarm agent on the local host and wait for it to come online before returning. 152 | * Unlike {@link #createSwarmClientWithName(String, String...)} no default CLI arguments are 153 | * passed. 154 | * 155 | * @param agentName The proposed name of the agent. 156 | * @param args The arguments to pass to the client. 157 | * @return The online node 158 | */ 159 | public synchronized Node createSwarmClientWithoutDefaultArgs(String agentName, String... args) 160 | throws InterruptedException, IOException { 161 | return createSwarmClientWithCommand( 162 | agentName, swarmClientJar -> getCommand(swarmClientJar, null, null, null, null, args)); 163 | } 164 | 165 | /** 166 | * Create a new Swarm agent on the local host and wait for it to come online before returning. 167 | * 168 | *

The function used to generate the launch CLI takes the client JAR path as an argument and 169 | * returns the list of commands, typically by invoking {@link #getCommand(Path, URL, String, 170 | * String, String, String...)}. 171 | * 172 | * @param agentName The proposed name of the agent. 173 | * @param commandGenerator Generator of the client launch CLI 174 | * @return The online node 175 | */ 176 | private Node createSwarmClientWithCommand(String agentName, Function> commandGenerator) 177 | throws InterruptedException, IOException { 178 | if (isActive) { 179 | throw new IllegalStateException( 180 | "You must first tear down the existing Swarm client with \"tearDown()\" before" 181 | + " creating a new one."); 182 | } 183 | 184 | // Download the Swarm client JAR from the Jenkins controller. 185 | Path swarmClientJar = Files.createTempFile(temporaryFolder.getRoot().toPath(), "swarm-client", ".jar"); 186 | download(swarmClientJar); 187 | 188 | final List command = commandGenerator.apply(swarmClientJar); 189 | 190 | logger.log(Level.INFO, "Starting client process."); 191 | try { 192 | ProcessBuilder pb = new ProcessBuilder(command); 193 | pb.directory(temporaryFolder.newFolder()); 194 | pb.environment().put("ON_SWARM_CLIENT", "true"); 195 | 196 | // Redirect standard out to a file and start a thread to tail its contents. 197 | Path stdout = Files.createTempFile(temporaryFolder.getRoot().toPath(), "stdout", ".log"); 198 | pb.redirectOutput(stdout.toFile()); 199 | stdoutTailer = new Tailer(stdout.toFile(), new SwarmClientTailerListener("Standard out"), 200L); 200 | stdoutThread = new Thread(stdoutTailer); 201 | stdoutThread.setDaemon(true); 202 | stdoutThread.start(); 203 | 204 | // Redirect standard error to a file and start a thread to tail its contents. 205 | Path stderr = Files.createTempFile(temporaryFolder.getRoot().toPath(), "stderr", ".log"); 206 | pb.redirectError(stderr.toFile()); 207 | stderrTailer = new Tailer(stderr.toFile(), new SwarmClientTailerListener("Standard error"), 200L); 208 | stderrThread = new Thread(stderrTailer); 209 | stderrThread.setDaemon(true); 210 | stderrThread.start(); 211 | 212 | // Start the process. 213 | process = pb.start(); 214 | } finally { 215 | isActive = true; 216 | } 217 | 218 | logger.log(Level.INFO, "Waiting for \"{0}\" to come online.", agentName); 219 | computer = waitOnline(agentName); 220 | assertNotNull(computer); 221 | assertTrue(computer.isOnline()); 222 | Node node = computer.getNode(); 223 | if (j.get().jenkins.getAuthorizationStrategy() instanceof ProjectMatrixAuthorizationStrategy) { 224 | assertNotNull(node.getNodeProperty(AuthorizationMatrixNodeProperty.class)); 225 | } 226 | assertNotNull(node); 227 | 228 | logger.log(Level.INFO, "\"{0}\" is now online.", node.getNodeName()); 229 | return node; 230 | } 231 | 232 | /** 233 | * A helper method to get the command-line arguments to start the client. 234 | * 235 | * @param swarmClientJar The path to the client JAR. 236 | * @param url The URL of the Jenkins controller, if one is being provided to the client. 237 | * @param agentName The proposed name of the agent. 238 | * @param args Any other desired arguments. 239 | */ 240 | public static List getCommand( 241 | Path swarmClientJar, URL url, String agentName, String username, String passwordFile, String... args) { 242 | List command = new ArrayList<>(); 243 | command.add(System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"); 244 | command.add("-Djava.awt.headless=true"); 245 | command.add("-Xmx64m"); 246 | command.add("-Xms64m"); 247 | command.add("-Dhudson.plugins.swarm.LabelFileWatcher.labelFileWatcherIntervalMillis=100"); 248 | command.add("-jar"); 249 | command.add(swarmClientJar.toString()); 250 | if (url != null) { 251 | command.add("-url"); 252 | command.add(url.toString()); 253 | } 254 | if (agentName != null) { 255 | command.add("-name"); 256 | command.add(agentName); 257 | } 258 | if (username != null) { 259 | command.add("-username"); 260 | command.add(username); 261 | } 262 | if (passwordFile != null) { 263 | command.add("-passwordFile"); 264 | command.add(passwordFile); 265 | } 266 | Collections.addAll(command, args); 267 | return command; 268 | } 269 | 270 | /** Download the Swarm Client from the given Jenkins URL into the given temporary directory. */ 271 | public void download(Path output) throws IOException { 272 | URL input; 273 | try { 274 | input = j.get() 275 | .getURL() 276 | .toURI() 277 | .resolve(new URI(null, "swarm/swarm-client.jar", null)) 278 | .toURL(); 279 | } catch (URISyntaxException e) { 280 | throw new RuntimeException(e); 281 | } 282 | logger.log(Level.INFO, "Downloading Swarm client from \"{0}\" to \"{1}\".", new Object[] {input, output}); 283 | FileUtils.copyURLToFile(input, output.toFile()); 284 | 285 | assertTrue(Files.isRegularFile(output)); 286 | assertThat(Files.size(output), greaterThan(1000L)); 287 | } 288 | 289 | /** Wait for the agent with the given name to come online against the given Jenkins instance. */ 290 | private Computer waitOnline(String agentName) throws InterruptedException { 291 | try (Timeout t = Timeout.limit(60, TimeUnit.SECONDS)) { 292 | Computer result = getComputer(agentName); 293 | while (result == null) { 294 | Thread.sleep(500L); 295 | result = getComputer(agentName); 296 | } 297 | 298 | while (!result.isOnline()) { 299 | Thread.sleep(500L); 300 | } 301 | 302 | return result; 303 | } 304 | } 305 | 306 | /** 307 | * Gets the computer corresponding to the proposed agent name. At this point we do not yet know 308 | * the final agent name, so we have to keep iterating until we find the agent that starts with 309 | * the proposed name. 310 | */ 311 | private Computer getComputer(String agentName) { 312 | List candidates = new ArrayList<>(); 313 | for (Computer candidate : j.get().jenkins.getComputers()) { 314 | if (candidate.getName().equals(agentName)) { 315 | return candidate; 316 | } else if (candidate.getName().startsWith(agentName + '-')) { 317 | candidates.add(candidate); 318 | } 319 | } 320 | if (candidates.isEmpty()) { 321 | return null; 322 | } 323 | assertEquals(candidates.size(), 1); 324 | return candidates.get(0); 325 | } 326 | 327 | public synchronized void tearDown() { 328 | if (!isActive) { 329 | throw new IllegalStateException("Must first create a Swarm client before attempting to tear it down."); 330 | } 331 | boolean interrupted = false; 332 | try { 333 | // Stop the process. 334 | if (process != null) { 335 | try { 336 | process.destroy(); 337 | assertTrue(process.waitFor(30, TimeUnit.SECONDS)); 338 | logger.log(Level.INFO, "Swarm client exited with exit value {0}.", process.exitValue()); 339 | } catch (IllegalThreadStateException e) { 340 | e.printStackTrace(); 341 | // ignore 342 | } catch (InterruptedException e) { 343 | e.printStackTrace(); 344 | interrupted = true; 345 | } finally { 346 | process = null; 347 | } 348 | } 349 | 350 | // Stop tailing standard out. 351 | if (stdoutTailer != null) { 352 | try { 353 | stdoutTailer.stop(); 354 | } finally { 355 | stdoutTailer = null; 356 | } 357 | } 358 | if (stdoutThread != null) { 359 | try { 360 | logger.log(Level.INFO, "Joining standard out tailer thread."); 361 | stdoutThread.join(30000L); 362 | } catch (InterruptedException e) { 363 | e.printStackTrace(); 364 | interrupted = true; 365 | } finally { 366 | stdoutThread = null; 367 | } 368 | } 369 | 370 | // Stop tailing standard error. 371 | if (stderrTailer != null) { 372 | try { 373 | stderrTailer.stop(); 374 | } finally { 375 | stderrTailer = null; 376 | } 377 | } 378 | if (stderrThread != null) { 379 | try { 380 | logger.log(Level.INFO, "Joining standard error tailer thread."); 381 | stderrThread.join(30000L); 382 | } catch (InterruptedException e) { 383 | e.printStackTrace(); 384 | interrupted = true; 385 | } finally { 386 | stderrThread = null; 387 | } 388 | } 389 | 390 | // Wait for the agent to be disconnected from the controller 391 | if (computer != null) { 392 | logger.log(Level.INFO, "Waiting for the agent to be disconnected from the controller."); 393 | try (Timeout t = Timeout.limit(60, TimeUnit.SECONDS)) { 394 | computer.disconnect(null); 395 | while (computer.isOnline()) { 396 | Thread.sleep(500L); 397 | } 398 | } catch (InterruptedException e) { 399 | e.printStackTrace(); 400 | interrupted = true; 401 | } finally { 402 | computer = null; 403 | } 404 | } 405 | } finally { 406 | isActive = false; 407 | if (interrupted) { 408 | Thread.currentThread().interrupt(); 409 | } 410 | } 411 | } 412 | 413 | public synchronized void tearDownAll() { 414 | if (isActive) { 415 | throw new IllegalStateException("Must be called after a tearDown() to fully clean up disconnected nodes"); 416 | } 417 | 418 | try { 419 | for (Computer comp : j.get().jenkins.getComputers()) { 420 | j.get().jenkins.removeNode(comp.getNode()); 421 | } 422 | } catch (IOException e) { 423 | throw new UncheckedIOException(e); 424 | } 425 | } 426 | 427 | /** Override to tear down your specific external resource. */ 428 | @Override 429 | protected synchronized void after() { 430 | if (isActive) { 431 | tearDown(); 432 | } 433 | } 434 | 435 | static class SwarmClientTailerListener extends TailerListenerAdapter { 436 | final String prefix; 437 | 438 | SwarmClientTailerListener(String prefix) { 439 | this.prefix = prefix; 440 | } 441 | 442 | @Override 443 | public void handle(String line) { 444 | logger.log(Level.INFO, prefix + ": {0}", line); 445 | } 446 | } 447 | } 448 | -------------------------------------------------------------------------------- /client/src/main/java/hudson/plugins/swarm/SwarmClient.java: -------------------------------------------------------------------------------- 1 | package hudson.plugins.swarm; 2 | 3 | import com.sun.net.httpserver.HttpServer; 4 | import hudson.remoting.Launcher; 5 | import io.micrometer.core.instrument.binder.jvm.ClassLoaderMetrics; 6 | import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; 7 | import io.micrometer.core.instrument.binder.jvm.JvmHeapPressureMetrics; 8 | import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; 9 | import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; 10 | import io.micrometer.core.instrument.binder.system.FileDescriptorMetrics; 11 | import io.micrometer.core.instrument.binder.system.ProcessorMetrics; 12 | import io.micrometer.core.instrument.binder.system.UptimeMetrics; 13 | import io.micrometer.prometheusmetrics.PrometheusConfig; 14 | import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; 15 | import java.io.File; 16 | import java.io.IOException; 17 | import java.io.InputStream; 18 | import java.io.OutputStream; 19 | import java.io.UncheckedIOException; 20 | import java.io.UnsupportedEncodingException; 21 | import java.net.CookieManager; 22 | import java.net.HttpURLConnection; 23 | import java.net.Inet4Address; 24 | import java.net.Inet6Address; 25 | import java.net.InetAddress; 26 | import java.net.InetSocketAddress; 27 | import java.net.MalformedURLException; 28 | import java.net.NetworkInterface; 29 | import java.net.SocketException; 30 | import java.net.URI; 31 | import java.net.URL; 32 | import java.net.URLEncoder; 33 | import java.net.http.HttpClient; 34 | import java.net.http.HttpRequest; 35 | import java.net.http.HttpResponse; 36 | import java.nio.charset.StandardCharsets; 37 | import java.nio.file.Files; 38 | import java.nio.file.Paths; 39 | import java.security.GeneralSecurityException; 40 | import java.security.MessageDigest; 41 | import java.security.NoSuchAlgorithmException; 42 | import java.security.SecureRandom; 43 | import java.security.cert.CertificateException; 44 | import java.security.cert.X509Certificate; 45 | import java.util.ArrayList; 46 | import java.util.Arrays; 47 | import java.util.Base64; 48 | import java.util.Collections; 49 | import java.util.List; 50 | import java.util.Locale; 51 | import java.util.Map; 52 | import java.util.Properties; 53 | import java.util.concurrent.TimeUnit; 54 | import java.util.logging.Level; 55 | import java.util.logging.Logger; 56 | import javax.net.ssl.KeyManager; 57 | import javax.net.ssl.SSLContext; 58 | import javax.net.ssl.TrustManager; 59 | import javax.net.ssl.X509TrustManager; 60 | import org.w3c.dom.Element; 61 | import org.w3c.dom.Node; 62 | import org.w3c.dom.Text; 63 | 64 | public class SwarmClient { 65 | 66 | private static final Logger logger = Logger.getLogger(SwarmClient.class.getName()); 67 | 68 | private final Options options; 69 | private final String hash; 70 | private String secret; 71 | private String name; 72 | private HttpServer prometheusServer = null; 73 | 74 | public SwarmClient(Options options) { 75 | this.options = options; 76 | if (!options.disableClientsUniqueId) { 77 | this.hash = hash(options.fsroot); 78 | } else { 79 | this.hash = ""; 80 | } 81 | this.name = options.name; 82 | 83 | if (options.labelsFile != null) { 84 | logger.info("Loading labels from " + options.labelsFile + "..."); 85 | try { 86 | String labels = Files.readString(Paths.get(options.labelsFile), StandardCharsets.UTF_8); 87 | options.labels.addAll(List.of(labels.trim().split("\\s+"))); 88 | logger.info("Labels found in file: " + labels); 89 | logger.info("Effective label list: " + Arrays.toString(options.labels.toArray())); 90 | } catch (IOException e) { 91 | throw new UncheckedIOException("Problem reading labels from file " + options.labelsFile, e); 92 | } 93 | } 94 | 95 | if (options.prometheusPort > 0) { 96 | startPrometheusService(options.prometheusPort); 97 | } 98 | } 99 | 100 | public String getName() { 101 | return name; 102 | } 103 | 104 | public List getOptionsLabels() { 105 | /* Note: these labels might differ from run-time values assigned 106 | * to an actual agent, if someone edits it via configure page */ 107 | return options.labels; 108 | } 109 | 110 | public URL getUrl() { 111 | logger.config("getUrl() invoked"); 112 | 113 | if (!options.url.endsWith("/")) { 114 | options.url += "/"; 115 | } 116 | 117 | try { 118 | return new URL(options.url); 119 | } catch (MalformedURLException e) { 120 | throw new UncheckedIOException(String.format("The URL %s is invalid", options.url), e); 121 | } 122 | } 123 | 124 | /** 125 | * This method blocks while the Swarm agent is connected. 126 | * 127 | *

Interrupt the thread to abort it and try connecting again. 128 | */ 129 | void connect(URL url) throws IOException, RetryException { 130 | List args = new ArrayList<>(); 131 | 132 | args.add("-url"); 133 | args.add(url.toString()); 134 | 135 | if (secret != null) { 136 | args.add("-secret"); 137 | args.add(secret); 138 | } 139 | 140 | args.add("-name"); 141 | args.add(name); 142 | 143 | if (options.disableSslVerification) { 144 | args.add("-noCertificateCheck"); 145 | } 146 | 147 | // if the tunnel option is set in the command line, use it 148 | if (options.tunnel != null) { 149 | args.add("-tunnel"); 150 | args.add(options.tunnel); 151 | logger.fine("Using tunnel through " + options.tunnel); 152 | } 153 | 154 | if (options.username != null && options.password != null && !options.webSocket) { 155 | args.add("-credentials"); 156 | args.add(options.username + ":" + options.password); 157 | } 158 | 159 | if (!options.disableWorkDir) { 160 | String workDirPath = options.workDir != null ? options.workDir.getPath() : options.fsroot.getPath(); 161 | args.add("-workDir"); 162 | args.add(workDirPath); 163 | 164 | if (options.internalDir != null) { 165 | args.add("-internalDir"); 166 | args.add(options.internalDir.getPath()); 167 | } 168 | 169 | if (options.failIfWorkDirIsMissing) { 170 | args.add("-failIfWorkDirIsMissing"); 171 | } 172 | } 173 | 174 | if (options.jarCache != null) { 175 | args.add("-jar-cache"); 176 | args.add(options.jarCache.getPath()); 177 | } 178 | 179 | /* 180 | * Swarm does its own retrying internally, so disable the retrying functionality in 181 | * Remoting. 182 | */ 183 | args.add("-noReconnect"); 184 | 185 | if (options.webSocket) { 186 | args.add("-webSocket"); 187 | 188 | if (options.webSocketHeaders != null) { 189 | for (Map.Entry entry : options.webSocketHeaders.entrySet()) { 190 | args.add("-webSocketHeader"); 191 | args.add(entry.getKey() + "=" + entry.getValue()); 192 | } 193 | } 194 | } 195 | 196 | try { 197 | Launcher.main(args.toArray(new String[0])); 198 | } catch (InterruptedException | RuntimeException e) { 199 | throw new RetryException("Failed to establish connection to " + url, e); 200 | } 201 | } 202 | 203 | @SuppressWarnings("lgtm[jenkins/unsafe-calls]") 204 | static HttpClient createHttpClient(Options clientOptions) { 205 | logger.fine("createHttpClient() invoked"); 206 | 207 | HttpClient.Builder builder = HttpClient.newBuilder(); 208 | 209 | // Set a cookie handler for storing the session associated with the CSRF crumb. 210 | builder.cookieHandler(new CookieManager()); 211 | 212 | if (clientOptions.disableSslVerification || !clientOptions.sslFingerprints.isEmpty()) { 213 | // Set the default SSL context for Remoting. 214 | SSLContext sslContext; 215 | try { 216 | sslContext = SSLContext.getInstance("TLS"); 217 | String trusted = clientOptions.disableSslVerification ? "" : clientOptions.sslFingerprints; 218 | sslContext.init( 219 | new KeyManager[0], new TrustManager[] {new DefaultTrustManager(trusted)}, new SecureRandom()); 220 | } catch (GeneralSecurityException e) { 221 | logger.log(Level.SEVERE, "An error occurred", e); 222 | throw new IllegalStateException(e); 223 | } 224 | builder.sslContext(sslContext); 225 | SSLContext.setDefault(sslContext); 226 | 227 | if (clientOptions.disableSslVerification) { 228 | System.setProperty("jdk.internal.httpclient.disableHostnameVerification", Boolean.toString(true)); 229 | } 230 | } 231 | 232 | return builder.build(); 233 | } 234 | 235 | static void addAuthorizationHeader(HttpRequest.Builder builder, Options clientOptions) { 236 | logger.fine("addAuthorizationHeader() invoked"); 237 | 238 | if (clientOptions.username != null && clientOptions.password != null) { 239 | logger.fine("Setting HttpClient credentials based on options passed"); 240 | 241 | String auth = clientOptions.username + ":" + clientOptions.password; 242 | String encoded = "Basic " + Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); 243 | builder.header("Authorization", encoded); 244 | } 245 | } 246 | 247 | private static synchronized Crumb getCsrfCrumb(HttpClient client, Options options, URL url) 248 | throws IOException, InterruptedException, RetryException { 249 | logger.finer("getCsrfCrumb() invoked"); 250 | 251 | String[] crumbResponse; 252 | 253 | URI uri = URI.create(url 254 | + "crumbIssuer/api/xml?xpath=" 255 | + URLEncoder.encode("concat(//crumbRequestField,\":\",//crumb)", StandardCharsets.UTF_8)); 256 | HttpRequest.Builder builder = HttpRequest.newBuilder(uri).GET(); 257 | SwarmClient.addAuthorizationHeader(builder, options); 258 | HttpRequest request = builder.build(); 259 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 260 | if (response.statusCode() != HttpURLConnection.HTTP_OK) { 261 | logger.log( 262 | Level.SEVERE, 263 | String.format( 264 | "Could not obtain CSRF crumb. Response code: %s%n%s", 265 | response.statusCode(), response.body())); 266 | if (response.statusCode() >= 500 && response.statusCode() < 600) 267 | throw new RetryException("Failed to obtain CSRF crumb due to an Internal Server " 268 | + "Error or similar condition. Response code: " + response.statusCode()); 269 | return null; 270 | } 271 | 272 | String crumbResponseString = response.body(); 273 | crumbResponse = crumbResponseString.split(":"); 274 | if (crumbResponse.length != 2) { 275 | logger.log(Level.SEVERE, "Unexpected CSRF crumb response: " + crumbResponseString); 276 | return null; 277 | } 278 | 279 | return new Crumb(crumbResponse[0], crumbResponse[1]); 280 | } 281 | 282 | void createSwarmAgent(URL url) throws IOException, InterruptedException, RetryException { 283 | logger.fine("createSwarmAgent() invoked"); 284 | 285 | String labelStr = String.join(" ", options.labels); 286 | StringBuilder toolLocationBuilder = new StringBuilder(); 287 | if (options.toolLocations != null) { 288 | for (Map.Entry toolLocation : options.toolLocations.entrySet()) { 289 | toolLocationBuilder.append( 290 | param("toolLocation", toolLocation.getKey() + ":" + toolLocation.getValue())); 291 | } 292 | } 293 | 294 | StringBuilder environmentVariablesBuilder = new StringBuilder(); 295 | if (options.environmentVariables != null) { 296 | for (Map.Entry environmentVariable : options.environmentVariables.entrySet()) { 297 | environmentVariablesBuilder.append(param( 298 | "environmentVariable", environmentVariable.getKey() + ":" + environmentVariable.getValue())); 299 | } 300 | } 301 | 302 | String sMyLabels = labelStr; 303 | if (sMyLabels.length() > 1000) { 304 | sMyLabels = ""; 305 | } 306 | 307 | Properties props = new Properties(); 308 | 309 | HttpClient client = createHttpClient(options); 310 | URI uri = URI.create(url 311 | + "plugin/swarm/createSlave?name=" 312 | + options.name 313 | + "&executors=" 314 | + options.executors 315 | + param("remoteFsRoot", options.fsroot.getAbsolutePath()) 316 | + param("description", options.description) 317 | + param("labels", sMyLabels) 318 | + toolLocationBuilder 319 | + environmentVariablesBuilder 320 | + param("mode", options.mode.toUpperCase(Locale.ENGLISH)) 321 | + param("hash", hash) 322 | + param("deleteExistingClients", Boolean.toString(options.deleteExistingClients)) 323 | + param("keepDisconnectedClients", Boolean.toString(options.keepDisconnectedClients))); 324 | HttpRequest.Builder builder = HttpRequest.newBuilder(uri).POST(HttpRequest.BodyPublishers.noBody()); 325 | SwarmClient.addAuthorizationHeader(builder, options); 326 | Crumb csrfCrumb = getCsrfCrumb(client, options, url); 327 | if (csrfCrumb != null) { 328 | builder.header(csrfCrumb.crumbRequestField, csrfCrumb.crumb); 329 | } 330 | HttpRequest request = builder.build(); 331 | 332 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); 333 | if (response.statusCode() != HttpURLConnection.HTTP_OK) { 334 | throw new RetryException(String.format( 335 | "Failed to create a Swarm agent on Jenkins. Response code: %s%n%s", 336 | response.statusCode(), new String(response.body().readAllBytes(), StandardCharsets.UTF_8))); 337 | } 338 | 339 | try (InputStream stream = response.body()) { 340 | props.load(stream); 341 | } 342 | 343 | String secret = props.getProperty("secret"); 344 | if (secret != null) { 345 | this.secret = secret.trim(); 346 | } 347 | 348 | String name = props.getProperty("name"); 349 | if (name == null) { 350 | this.name = options.name; 351 | return; 352 | } 353 | name = name.trim(); 354 | if (name.isEmpty()) { 355 | this.name = options.name; 356 | return; 357 | } 358 | this.name = name; 359 | 360 | // special handling for very long lists of labels (avoids 413 FULL Header error) 361 | if (sMyLabels.length() == 0 && labelStr.length() > 0) { 362 | String[] lLabels = labelStr.split("\\s+"); 363 | StringBuilder sb = new StringBuilder(); 364 | for (String s : lLabels) { 365 | sb.append(s); 366 | sb.append(" "); 367 | if (sb.length() > 1000) { 368 | postLabelAppend(name, sb.toString(), client, options, url); 369 | sb = new StringBuilder(); 370 | } 371 | } 372 | if (sb.length() > 0) { 373 | postLabelAppend(name, sb.toString(), client, options, url); 374 | } 375 | } 376 | } 377 | 378 | static synchronized void postLabelRemove(String name, String labels, HttpClient client, Options options, URL url) 379 | throws IOException, InterruptedException, RetryException { 380 | URI uri = URI.create(url + "plugin/swarm/removeSlaveLabels?name=" + name + SwarmClient.param("labels", labels)); 381 | HttpRequest.Builder builder = HttpRequest.newBuilder(uri).POST(HttpRequest.BodyPublishers.noBody()); 382 | SwarmClient.addAuthorizationHeader(builder, options); 383 | Crumb csrfCrumb = SwarmClient.getCsrfCrumb(client, options, url); 384 | if (csrfCrumb != null) { 385 | builder.header(csrfCrumb.crumbRequestField, csrfCrumb.crumb); 386 | } 387 | HttpRequest request = builder.build(); 388 | 389 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 390 | if (response.statusCode() != HttpURLConnection.HTTP_OK) { 391 | throw new RetryException(String.format( 392 | "Failed to remove agent labels. Response code: %s%n%s", response.statusCode(), response.body())); 393 | } 394 | } 395 | 396 | static synchronized void postLabelAppend(String name, String labels, HttpClient client, Options options, URL url) 397 | throws IOException, InterruptedException, RetryException { 398 | URI uri = URI.create(url + "plugin/swarm/addSlaveLabels?name=" + name + param("labels", labels)); 399 | HttpRequest.Builder builder = HttpRequest.newBuilder(uri).POST(HttpRequest.BodyPublishers.noBody()); 400 | SwarmClient.addAuthorizationHeader(builder, options); 401 | Crumb csrfCrumb = getCsrfCrumb(client, options, url); 402 | if (csrfCrumb != null) { 403 | builder.header(csrfCrumb.crumbRequestField, csrfCrumb.crumb); 404 | } 405 | HttpRequest request = builder.build(); 406 | 407 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 408 | if (response.statusCode() != HttpURLConnection.HTTP_OK) { 409 | throw new RetryException(String.format( 410 | "Failed to update agent labels. Response code: %s%n%s", response.statusCode(), response.body())); 411 | } 412 | } 413 | 414 | private static synchronized String encode(String value) throws UnsupportedEncodingException { 415 | logger.finer("encode() invoked"); 416 | 417 | return URLEncoder.encode(value, StandardCharsets.UTF_8); 418 | } 419 | 420 | private static synchronized String param(String name, String value) throws UnsupportedEncodingException { 421 | logger.finer("param() invoked"); 422 | 423 | if (value == null) { 424 | return ""; 425 | } 426 | return "&" + name + "=" + encode(value); 427 | } 428 | 429 | static String getChildElementString(Element parent, String tagName) { 430 | logger.finer("getChildElementString() invoked"); 431 | 432 | for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { 433 | if (node instanceof Element) { 434 | Element element = (Element) node; 435 | if (element.getTagName().equals(tagName)) { 436 | StringBuilder buf = new StringBuilder(); 437 | for (node = element.getFirstChild(); node != null; node = node.getNextSibling()) { 438 | if (node instanceof Text) { 439 | buf.append(node.getTextContent()); 440 | } 441 | } 442 | return buf.toString(); 443 | } 444 | } 445 | } 446 | return null; 447 | } 448 | 449 | /** 450 | * Returns a hash that should be consistent for any individual swarm client (as long as it has a 451 | * persistent IP) and should be unique to that client. 452 | * 453 | * @param remoteFsRoot the file system root should be part of the hash (to support multiple 454 | * swarm clients from the same machine) 455 | * @return our best effort at a consistent hash 456 | */ 457 | private static String hash(File remoteFsRoot) { 458 | logger.config("hash() invoked"); 459 | 460 | StringBuilder buf = new StringBuilder(); 461 | try { 462 | buf.append(remoteFsRoot.getCanonicalPath()).append('\n'); 463 | } catch (IOException e) { 464 | logger.log(Level.FINER, "hash() IOException - may be normal?", e); 465 | buf.append(remoteFsRoot.getAbsolutePath()).append('\n'); 466 | } 467 | try { 468 | for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) { 469 | for (InetAddress ia : Collections.list(ni.getInetAddresses())) { 470 | if (ia instanceof Inet4Address) { 471 | buf.append(ia.getHostAddress()).append('\n'); 472 | } else if (ia instanceof Inet6Address) { 473 | buf.append(ia.getHostAddress()).append('\n'); 474 | } 475 | } 476 | byte[] hardwareAddress = ni.getHardwareAddress(); 477 | if (hardwareAddress != null) { 478 | buf.append(Arrays.toString(hardwareAddress)); 479 | } 480 | } 481 | } catch (SocketException e) { 482 | // oh well we tried 483 | logger.log(Level.FINEST, "hash() SocketException - 'oh well we tried'", e); 484 | } 485 | MessageDigest md; 486 | try { 487 | md = MessageDigest.getInstance("MD5"); 488 | } catch (NoSuchAlgorithmException e) { 489 | throw new IllegalStateException(e); 490 | } 491 | byte[] digest = md.digest(buf.toString().getBytes(StandardCharsets.UTF_8)); 492 | return encodeHex(digest).substring(0, 8); 493 | } 494 | 495 | private static String encodeHex(byte[] data) { 496 | StringBuilder sb = new StringBuilder(data.length * 2); 497 | for (byte b : data) { 498 | sb.append(String.format("%02x", b)); 499 | } 500 | return sb.toString(); 501 | } 502 | 503 | @SuppressWarnings("lgtm[jenkins/unsafe-calls]") 504 | public void exitWithStatus(int status) { 505 | if (prometheusServer != null) { 506 | prometheusServer.stop(1); 507 | } 508 | System.exit(status); 509 | } 510 | 511 | public void sleepSeconds(int waitTime) throws InterruptedException { 512 | TimeUnit.SECONDS.sleep(waitTime); 513 | } 514 | 515 | private void startPrometheusService(int port) { 516 | logger.fine("Starting Prometheus service on port " + port); 517 | PrometheusMeterRegistry prometheusRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); 518 | // Add some standard metrics to the registry 519 | new ClassLoaderMetrics().bindTo(prometheusRegistry); 520 | new FileDescriptorMetrics().bindTo(prometheusRegistry); 521 | new JvmGcMetrics().bindTo(prometheusRegistry); 522 | new JvmHeapPressureMetrics().bindTo(prometheusRegistry); 523 | new JvmMemoryMetrics().bindTo(prometheusRegistry); 524 | new JvmThreadMetrics().bindTo(prometheusRegistry); 525 | new ProcessorMetrics().bindTo(prometheusRegistry); 526 | new UptimeMetrics().bindTo(prometheusRegistry); 527 | 528 | try { 529 | prometheusServer = HttpServer.create(new InetSocketAddress(port), 0); 530 | prometheusServer.createContext("/prometheus", httpExchange -> { 531 | String response = prometheusRegistry.scrape(); 532 | byte[] responseContent = response.getBytes(StandardCharsets.UTF_8); 533 | httpExchange.sendResponseHeaders(200, responseContent.length); 534 | try (OutputStream os = httpExchange.getResponseBody()) { 535 | os.write(responseContent); 536 | } 537 | }); 538 | 539 | new Thread(prometheusServer::start).start(); 540 | } catch (IOException e) { 541 | logger.severe("Failed to start Prometheus service: " + e.getMessage()); 542 | throw new UncheckedIOException(e); 543 | } 544 | logger.info("Started Prometheus service on port " + port); 545 | } 546 | 547 | private static class DefaultTrustManager implements X509TrustManager { 548 | 549 | final List allowedFingerprints = new ArrayList<>(); 550 | 551 | final List acceptedIssuers = new ArrayList<>(); 552 | 553 | @Override 554 | public void checkClientTrusted(X509Certificate[] x509Certificates, String s) {} 555 | 556 | @Override 557 | public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { 558 | if (allowedFingerprints.isEmpty()) { 559 | return; 560 | } 561 | 562 | List list = new ArrayList<>(); 563 | 564 | for (X509Certificate cert : x509Certificates) { 565 | MessageDigest md; 566 | try { 567 | md = MessageDigest.getInstance("SHA-256"); 568 | } catch (NoSuchAlgorithmException e) { 569 | throw new IllegalStateException(e); 570 | } 571 | byte[] digest = md.digest(cert.getEncoded()); 572 | String fingerprint = encodeHex(digest); 573 | logger.fine("Check fingerprint: " + fingerprint); 574 | if (allowedFingerprints.contains(fingerprint)) { 575 | list.add(cert); 576 | logger.fine("Found allowed certificate: " + cert); 577 | } 578 | } 579 | 580 | if (list.isEmpty()) { 581 | throw new CertificateException("Fingerprint mismatch"); 582 | } 583 | 584 | acceptedIssuers.addAll(list); 585 | } 586 | 587 | @Override 588 | public X509Certificate[] getAcceptedIssuers() { 589 | return acceptedIssuers.toArray(new X509Certificate[0]); 590 | } 591 | 592 | public DefaultTrustManager(String fingerprints) { 593 | if (fingerprints.isEmpty()) { 594 | return; 595 | } 596 | 597 | for (String fingerprint : fingerprints.split("\\s+")) { 598 | String unified = fingerprint.toLowerCase().replace(":", ""); 599 | logger.fine("Add allowed fingerprint: " + unified); 600 | allowedFingerprints.add(unified); 601 | } 602 | } 603 | } 604 | 605 | private static class Crumb { 606 | final String crumb; 607 | final String crumbRequestField; 608 | 609 | Crumb(String crumbRequestField, String crumb) { 610 | this.crumbRequestField = crumbRequestField; 611 | this.crumb = crumb; 612 | } 613 | } 614 | } 615 | --------------------------------------------------------------------------------