├── 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 |
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 extends NodeProperty>> 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 extends NodeProperty>> 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 |
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 | 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 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 Interrupt the thread to abort it and try connecting again.
128 | */
129 | void connect(URL url) throws IOException, RetryException {
130 | List