Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JEP-222] WebSocket-based agents #357

Merged
merged 39 commits into from
Jan 14, 2020
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a5f4f7a
Add a WebSocket agent client.
jglick Nov 13, 2019
944b358
Reworked protocol to negotiate remote capabilities.
jglick Nov 21, 2019
43c6978
Comments.
jglick Nov 21, 2019
e4fe758
Simplifying handshake to use HTTP headers.
jglick Nov 21, 2019
12afe79
SpotBugs
jglick Nov 22, 2019
89397b1
Merge branch 'publish-incrementals' into websocket
jglick Nov 26, 2019
abd931b
Merge branch 'master' into websocket
jglick Dec 6, 2019
561713b
Add a -webSocket launch option.
jglick Dec 10, 2019
c877936
Better comment.
jglick Dec 10, 2019
ae63bd9
Misleading Javadoc.
jglick Dec 10, 2019
2f77265
Handling some close and error methods.
jglick Dec 10, 2019
aefc7b4
Checking X-Remoting-Minimum-Version if sent.
jglick Dec 11, 2019
4c701f3
SpotBugs
jglick Dec 11, 2019
47ea945
More targeted SpotBugs annotation placement.
jglick Dec 11, 2019
86cea5b
Documenting options incompatible with -webSocket.
jglick Dec 11, 2019
02db50c
Comments.
jglick Dec 13, 2019
6263aae
nginx urgh https://stackoverflow.com/a/49801326/12916
jglick Dec 13, 2019
827bfca
Trying to enable JAR cache.
jglick Dec 13, 2019
e25ee2c
JnlpConnectionState.fireXXX methods need to be public to support othe…
jglick Dec 13, 2019
cf08bcf
JnlpConnectionState.getRemoteEndpointDescription introduced to avoid …
jglick Dec 13, 2019
f2549aa
Do not advertise tyrus-standalone-client-jdk to Maven dependencies, t…
jglick Dec 16, 2019
ee1ecd9
SpotBugs
jglick Dec 17, 2019
50d4df7
Merge branch 'master' of github.com:jenkinsci/remoting into websocket
jglick Dec 17, 2019
21bf706
Shade dependencies needed for agent.jar.
jglick Dec 21, 2019
a54c157
Merge branch 'master' of github.com:jenkinsci/remoting into websocket
jglick Jan 3, 2020
526baab
More intuitive log message, as suggested by @jeffret-b.
jglick Jan 3, 2020
ce68090
FindSecBugs
jglick Jan 3, 2020
f77a3cf
Some updates to documentation to reflect WebSocket mode.
jglick Jan 3, 2020
0682149
Fixed formatting
jglick Jan 3, 2020
87ff8af
Apply suggestions from code review
jglick Jan 4, 2020
84d9d71
Merge branch 'master' of github.com:jenkinsci/remoting into websocket
jglick Jan 6, 2020
d220515
Expect to indicate version in which -webSocket was introduced.
jglick Jan 6, 2020
977bcc1
Introduced constant for X-Remoting-Minimum-Version.
jglick Jan 6, 2020
0cc1903
Merge branch 'websocket' of github.com:jglick/remoting into websocket
jglick Jan 6, 2020
91f8990
Take advantage of args4j declarative depends/forbids. Also reword err…
jglick Jan 6, 2020
739e338
No need to explicitly close connections after EngineListener.error: M…
jglick Jan 6, 2020
7eda193
Implement reconnection in WebSocket mode.
jglick Jan 6, 2020
b779630
Correcting option name.
jglick Jan 6, 2020
c238b7c
FindSecBugs
jglick Jan 6, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ THE SOFTWARE.
<artifactId>animal-sniffer-annotations</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.glassfish.tyrus.bundles</groupId>
<artifactId>tyrus-standalone-client-jdk</artifactId>
<version>1.12</version>
<scope>provided</scope>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>junit</groupId>
Expand Down Expand Up @@ -342,6 +348,18 @@ THE SOFTWARE.
<includeGroupIds>org.jenkins-ci</includeGroupIds>
</configuration>
</execution>
<execution>
<id>bundle-tyrus</id>
<phase>process-classes</phase>
<goals>
<goal>unpack-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.outputDirectory}</outputDirectory>
<includeScope>provided</includeScope>
<includeArtifactIds>tyrus-standalone-client-jdk</includeArtifactIds>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ public static interface ByteArrayReceiver {
*
* As discussed in {@link AbstractByteArrayCommandTransport#writeBlock(Channel, byte[])},
* the block boundary is significant.
* @see CommandReceiver#handle
*/
void handle(byte[] payload);

/**
* See {@link CommandReceiver#handle(Command)} for details.
* @see CommandReceiver#terminate
*/
void terminate(IOException e);
}
Expand Down
38 changes: 36 additions & 2 deletions src/main/java/hudson/remoting/Capability.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package hudson.remoting;

import hudson.remoting.Channel.Mode;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
Expand All @@ -24,6 +26,12 @@
* @see Channel#remoteCapability
*/
public final class Capability implements Serializable {

/**
* Key usable as a WebSocket HTTP header to negotiate capabilities.
*/
public static final String KEY = "X-Remoting-Capability";

/**
* Bit mask of optional capabilities.
*/
Expand Down Expand Up @@ -120,10 +128,17 @@ public boolean supportsProxyExceptionFallback() {

//TODO: ideally preamble handling needs to be reworked in order to avoid FB suppression
/**
* Writes out the capacity preamble.
* Writes {@link #PREAMBLE} then uses {@link #write}.
*/
void writePreamble(OutputStream os) throws IOException {
os.write(PREAMBLE);
write(os);
}

/**
* Writes this capability to a stream.
*/
private void write(OutputStream os) throws IOException {
try (ObjectOutputStream oos = new ObjectOutputStream(Mode.TEXT.wrap(os)) {
@Override
public void close() throws IOException {
Expand All @@ -143,7 +158,7 @@ protected void annotateClass(Class<?> c) throws IOException {
}

/**
* The opposite operation of {@link #writePreamble(OutputStream)}.
* The opposite operation of {@link #write}.
*/
public static Capability read(InputStream is) throws IOException {
try (ObjectInputStream ois = new ObjectInputStream(Mode.TEXT.wrap(is)) {
Expand Down Expand Up @@ -171,6 +186,25 @@ public void close() throws IOException {
}
}

/**
* Uses {@link #write} to serialize this object to a Base64-encoded ASCII stream.
*/
public String toASCII() throws IOException {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
write(baos);
return baos.toString("US-ASCII");
}
}

/**
* The inverse of {@link #toASCII}.
*/
public static Capability fromASCII(String ascii) throws IOException {
try (ByteArrayInputStream bais = new ByteArrayInputStream(ascii.getBytes(StandardCharsets.US_ASCII))) {
return Capability.read(bais);
}
}

private static final long serialVersionUID = 1L;

/**
Expand Down
132 changes: 132 additions & 0 deletions src/main/java/hudson/remoting/Engine.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@
*/
package hudson.remoting;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.remoting.Channel.Mode;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.Socket;
import java.net.URI;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.security.AccessController;
import java.security.KeyManagementException;
Expand All @@ -44,13 +47,15 @@
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPublicKey;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.CheckForNull;
Expand All @@ -61,6 +66,13 @@
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.websocket.ClientEndpointConfig;
import javax.websocket.CloseReason;
import javax.websocket.ContainerProvider;
import javax.websocket.Endpoint;
import javax.websocket.EndpointConfig;
import javax.websocket.HandshakeResponse;
import javax.websocket.Session;

import org.jenkinsci.remoting.engine.JnlpEndpointResolver;
import org.jenkinsci.remoting.engine.Jnlp4ConnectionState;
Expand All @@ -78,6 +90,7 @@
import org.jenkinsci.remoting.protocol.cert.PublicKeyMatchingX509ExtendedTrustManager;
import org.jenkinsci.remoting.protocol.impl.ConnectionRefusalException;
import org.jenkinsci.remoting.util.KeyUtils;
import org.jenkinsci.remoting.util.VersionNumber;

/**
* Agent engine that proactively connects to Jenkins master.
Expand Down Expand Up @@ -134,6 +147,7 @@ public Thread newThread(final Runnable r) {
private URL hudsonUrl;
private final String secretKey;
public final String slaveName;
private boolean webSocket;
private String credentials;
private String proxyCredentials = System.getProperty("proxyCredentials");

Expand Down Expand Up @@ -322,6 +336,10 @@ public URL getHudsonUrl() {
return hudsonUrl;
}

public void setWebSocket(boolean webSocket) {
this.webSocket = webSocket;
}

/**
* If set, connect to the specified host and port instead of connecting directly to Jenkins.
* @param tunnel Value. {@code null} to disable tunneling
Expand Down Expand Up @@ -439,6 +457,10 @@ public void removeListener(EngineListener el) {

@Override
public void run() {
if (webSocket) {
runWebSocket();
return;
}
// Create the engine
try {
IOHub hub = IOHub.create(executor);
Expand Down Expand Up @@ -494,6 +516,116 @@ public void run() {
}
}

@SuppressFBWarnings(value = "REC_CATCH_EXCEPTION", justification = "checked exceptions were a mistake to begin with")
private void runWebSocket() {
try {
AtomicReference<Channel> ch = new AtomicReference<>();
String localCap = new Capability().toASCII();
class HeaderHandler extends ClientEndpointConfig.Configurator {
Capability remoteCapability = new Capability();
@Override
public void beforeRequest(Map<String, List<String>> headers) {
headers.put(JnlpConnectionState.CLIENT_NAME_KEY, Collections.singletonList(slaveName));
headers.put(JnlpConnectionState.SECRET_KEY, Collections.singletonList(secretKey));
headers.put(Capability.KEY, Collections.singletonList(localCap));
// TODO use JnlpConnectionState.COOKIE_KEY somehow (see EngineJnlpConnectionStateListener.afterChannel)
LOGGER.fine(() -> "Sending: " + headers);
}
@Override
public void afterResponse(HandshakeResponse hr) {
LOGGER.fine(() -> "Receiving: " + hr.getHeaders());
List<String> remotingMinimumVersion = hr.getHeaders().get("X-Remoting-Minimum-Version");
if (remotingMinimumVersion != null && !remotingMinimumVersion.isEmpty()) {
VersionNumber minimumSupportedVersion = new VersionNumber(remotingMinimumVersion.get(0));
VersionNumber currentVersion = new VersionNumber(Launcher.VERSION);
if (currentVersion.isOlderThan(minimumSupportedVersion)) {
// TODO these errors should trigger a connection close
events.error(new IOException("Agent version " + minimumSupportedVersion + " or newer is required."));
}
}
try {
remoteCapability = Capability.fromASCII(hr.getHeaders().get(Capability.KEY).get(0));
LOGGER.fine(() -> "received " + remoteCapability);
} catch (IOException x) {
events.error(x);
}
}
}
HeaderHandler headerHandler = new HeaderHandler();
class AgentEndpoint extends Endpoint {
@SuppressFBWarnings(value = "UWF_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR", justification = "just trust me here")
AbstractByteArrayCommandTransport.ByteArrayReceiver receiver;
@Override
public void onOpen(Session session, EndpointConfig config) {
events.status("WebSocket connection open");
session.addMessageHandler(byte[].class, this::onMessage);
try {
ch.set(new ChannelBuilder(slaveName, executor).
withJarCacheOrDefault(jarCache). // unless EngineJnlpConnectionStateListener can be used for this purpose
build(new Transport(session)));
} catch (IOException x) {
events.error(x);
}
}
private void onMessage(byte[] message) {
LOGGER.finest(() -> "received message of length " + message.length);
receiver.handle(message);
}
@Override
public void onClose(Session session, CloseReason closeReason) {
LOGGER.fine(() -> "onClose: " + closeReason);
receiver.terminate(new ChannelClosedException(ch.get(), null));
}
@Override
public void onError(Session session, Throwable x) {
LOGGER.log(Level.FINE, null, x);
receiver.terminate(new ChannelClosedException(ch.get(), x));
}
class Transport extends AbstractByteArrayCommandTransport {
final Session session;
Transport(Session session) {
this.session = session;
}
@Override
public void setup(AbstractByteArrayCommandTransport.ByteArrayReceiver _receiver) {
events.status("Setting up channel");
receiver = _receiver;
}
@Override
public void writeBlock(Channel channel, byte[] payload) throws IOException {
LOGGER.finest(() -> "sending message of length " + payload.length);
session.getBasicRemote().sendBinary(ByteBuffer.wrap(payload));
}
@Override
public Capability getRemoteCapability() throws IOException {
return headerHandler.remoteCapability;
}
@Override
public void closeWrite() throws IOException {
events.status("Write side closed");
session.close();
}
@Override
public void closeRead() throws IOException {
events.status("Read side closed");
session.close();
}
}
}
ContainerProvider.getWebSocketContainer().connectToServer(new AgentEndpoint(),
ClientEndpointConfig.Builder.create().configurator(headerHandler).build(), URI.create(candidateUrls.get(0).toString().replaceFirst("^http", "ws") + "wsagents/"));
while (ch.get() == null) {
Thread.sleep(100);
}
LOGGER.info(() -> "Waiting for channel");
ch.get().join();
// TODO handle multiple candidate URLs
// TODO handle reconnection
} catch (Exception e) {
events.error(e);
}
}

@SuppressWarnings({"ThrowableInstanceNeverThrown"})
private void innerRun(IOHub hub, SSLContext context, ExecutorService service) {
// Create the protocols that will be attempted to connect to the master.
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/hudson/remoting/jnlp/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ public class Main {
usage="Specify the Jenkins root URLs to connect to.")
public List<URL> urls = new ArrayList<>();

@Option(name="-webSocket",
usage="Make a WebSocket connection to Jenkins rather than using the TCP port.")
public boolean webSocket;

@Option(name="-credentials",metaVar="USER:PASSWORD",
usage="HTTP BASIC AUTH header to pass in for making HTTP requests.")
public String credentials;
Expand Down Expand Up @@ -267,6 +271,38 @@ public static void _main(String[] args) throws IOException, InterruptedException
if(m.urls.isEmpty() && m.directConnection == null) {
throw new CmdLineException(p, "At least one -url option is required.", null);
}
if (m.webSocket) {
if (m.urls.isEmpty()) {
throw new CmdLineException(p, "-url is required in -webSocket mode", null);
}
if (m.urls.size() > 1) {
throw new CmdLineException(p, "multiple -url is not currently supported in -webSocket mode", null);
}
if (m.directConnection != null) {
throw new CmdLineException(p, "-webSocket and -direct are mutually exclusive", null);
}
if (m.tunnel != null) {
throw new CmdLineException(p, "-tunnel is not currently supported in -webSocket mode", null);
}
if (m.credentials != null) {
throw new CmdLineException(p, "-credentials is not currently supported in -webSocket mode", null);
}
if (m.proxyCredentials != null) {
throw new CmdLineException(p, "-proxyCredentials is not currently supported in -webSocket mode", null);
}
if (m.candidateCertificates != null) {
throw new CmdLineException(p, "-candidateCertificates is not currently supported in -webSocket mode", null);
}
if (m.disableHttpsCertValidation) {
throw new CmdLineException(p, "-disableHttpsCertValidation is not currently supported in -webSocket mode", null);
}
if (m.noKeepAlive) {
throw new CmdLineException(p, "-noKeepAlive is not currently supported in -webSocket mode", null);
}
if (m.noReconnect) {
throw new CmdLineException(p, "-noReconnect is not currently supported in -webSocket mode", null);
}
}
m.main();
}

Expand All @@ -290,6 +326,7 @@ public Engine createEngine() {
Engine engine = new Engine(
headlessMode ? new CuiListener() : new GuiListener(),
urls, args.get(0), agentName, directConnection, instanceIdentity, new HashSet<>(protocols));
engine.setWebSocket(webSocket);
if(tunnel!=null)
engine.setTunnel(tunnel);
if(credentials!=null)
Expand Down
Loading