Skip to content

Commit 0d059c7

Browse files
authored
OutboundAgent test utility (#607)
* `OutboundAgent` test utility * Basic smoke test using `RealJenkinsRule`
1 parent 6c88f7f commit 0d059c7

File tree

3 files changed

+192
-0
lines changed

3 files changed

+192
-0
lines changed

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
<jenkins.version>${jenkins.baseline}.3</jenkins.version>
8484
<hpi.compatibleSinceVersion>3.0.0</hpi.compatibleSinceVersion>
8585
<spotless.check.skip>false</spotless.check.skip>
86+
<no-test-jar>false</no-test-jar>
8687
</properties>
8788

8889
<dependencyManagement>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright 2025 CloudBees, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
25+
package test.ssh_agent;
26+
27+
import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey;
28+
import com.cloudbees.plugins.credentials.CredentialsProvider;
29+
import com.cloudbees.plugins.credentials.CredentialsScope;
30+
import com.cloudbees.plugins.credentials.domains.Domain;
31+
import hudson.Functions;
32+
import hudson.plugins.sshslaves.SSHLauncher;
33+
import hudson.slaves.DumbSlave;
34+
import java.io.ByteArrayOutputStream;
35+
import java.io.Serializable;
36+
import java.nio.charset.StandardCharsets;
37+
import org.apache.sshd.common.config.keys.KeyUtils;
38+
import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter;
39+
import org.apache.sshd.common.keyprovider.KeyPairProvider;
40+
import org.jvnet.hudson.test.JenkinsRule;
41+
import org.testcontainers.DockerClientFactory;
42+
import org.testcontainers.containers.GenericContainer;
43+
44+
/**
45+
* Test utility to create an outbound agent.
46+
* Will use Docker when it is available (Testcontainers must be in your plugin classpath),
47+
* which is preferable as it ensures that the process and filesystem namespace for the agent
48+
* is distinct from that of the controller.
49+
* Otherwise it falls back to running an agent process locally.
50+
*/
51+
public final class OutboundAgent implements AutoCloseable {
52+
53+
private String image = "jenkins/ssh-agent";
54+
55+
private SSHAgentContainer container;
56+
57+
public OutboundAgent() {}
58+
59+
/**
60+
* Overrides the container image, by default {@code jenkins/ssh-agent} (latest).
61+
*/
62+
public OutboundAgent withImage(String image) {
63+
this.image = image;
64+
return this;
65+
}
66+
67+
private static final class SSHAgentContainer extends GenericContainer<SSHAgentContainer> {
68+
final String privateKey;
69+
70+
SSHAgentContainer(String image) {
71+
super(image);
72+
try {
73+
var kp = KeyUtils.generateKeyPair(KeyPairProvider.SSH_RSA, 2048);
74+
var kprw = new OpenSSHKeyPairResourceWriter();
75+
var baos = new ByteArrayOutputStream();
76+
kprw.writePublicKey(kp, null, baos);
77+
var pub = baos.toString(StandardCharsets.US_ASCII);
78+
baos.reset();
79+
kprw.writePrivateKey(kp, null, null, baos);
80+
privateKey = baos.toString(StandardCharsets.US_ASCII);
81+
withEnv("JENKINS_AGENT_SSH_PUBKEY", pub);
82+
withExposedPorts(22);
83+
} catch (Exception x) {
84+
throw new AssertionError(x);
85+
}
86+
}
87+
}
88+
89+
/**
90+
* Start the container, if Docker is available.
91+
* @return Docker connection details, or null if running locally; pass to {@link #createAgent}
92+
*/
93+
public ConnectionDetails start() throws Exception {
94+
if (!Functions.isWindows() && DockerClientFactory.instance().isDockerAvailable()) {
95+
container = new SSHAgentContainer(image);
96+
container.start();
97+
return new ConnectionDetails(container.getHost(), container.getMappedPort(22), container.privateKey);
98+
} else {
99+
return null;
100+
}
101+
}
102+
103+
/**
104+
* Treat as opaque between {@link #start} and {@link #createAgent}.
105+
*/
106+
public record ConnectionDetails(String host, int port, String privateKey) implements Serializable {}
107+
108+
/**
109+
* Create an agent.
110+
* @param rule this should run in the controller’s’ JVM, unlike {@link #start}
111+
* @param name agent name
112+
* @param connectionDetails connection details, or null to run a local agent
113+
* @see JenkinsRule#waitOnline
114+
*/
115+
public static void createAgent(JenkinsRule rule, String name, ConnectionDetails connectionDetails)
116+
throws Exception {
117+
if (connectionDetails != null) {
118+
var creds = new BasicSSHUserPrivateKey(
119+
CredentialsScope.GLOBAL,
120+
null,
121+
"jenkins",
122+
new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(connectionDetails.privateKey),
123+
null,
124+
null);
125+
CredentialsProvider.lookupStores(rule.jenkins).iterator().next().addCredentials(Domain.global(), creds);
126+
rule.jenkins.addNode(new DumbSlave(
127+
name,
128+
"/home/jenkins/agent",
129+
new SSHLauncher(connectionDetails.host, connectionDetails.port, creds.getId())));
130+
} else {
131+
rule.createSlave(name, null, null);
132+
}
133+
}
134+
135+
@Override
136+
public void close() throws Exception {
137+
if (container != null) {
138+
container.close();
139+
}
140+
}
141+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright 2025 CloudBees, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
25+
package test.ssh_agent;
26+
27+
import hudson.model.Slave;
28+
import org.junit.Rule;
29+
import org.junit.Test;
30+
import org.jvnet.hudson.test.RealJenkinsRule;
31+
32+
public final class OutboundAgentRJRTest {
33+
34+
@Rule
35+
public final RealJenkinsRule rr = new RealJenkinsRule();
36+
37+
@Test
38+
public void smokes() throws Throwable {
39+
rr.startJenkins();
40+
try (var outbountAgent = new OutboundAgent()) {
41+
rr.runRemotely(OutboundAgent::createAgent, "remote", outbountAgent.start());
42+
rr.run(r -> {
43+
var agent = (Slave) r.jenkins.getNode("remote");
44+
r.waitOnline(agent);
45+
System.err.println(
46+
"Running in " + agent.toComputer().getEnvironment().get("PWD"));
47+
});
48+
}
49+
}
50+
}

0 commit comments

Comments
 (0)