From dd7b16075ea2f5842277b34894f9fcec6b9ba994 Mon Sep 17 00:00:00 2001 From: Adrian Cole Date: Thu, 8 Mar 2012 15:48:51 -0800 Subject: [PATCH] Issue 861:SSHClient should provide access to input/output streams --- .../jclouds/compute/domain/ExecChannel.java | 96 +++++++++++++++++++ .../main/java/org/jclouds/ssh/SshClient.java | 18 +++- .../org/jclouds/ssh/jsch/JschSshClient.java | 80 ++++++++++++++-- .../ssh/jsch/JschSshClientLiveTest.java | 34 +++++++ .../java/org/jclouds/sshj/SshjSshClient.java | 58 +++++++++++ .../jclouds/sshj/SshjSshClientLiveTest.java | 34 ++++++- 6 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 compute/src/main/java/org/jclouds/compute/domain/ExecChannel.java diff --git a/compute/src/main/java/org/jclouds/compute/domain/ExecChannel.java b/compute/src/main/java/org/jclouds/compute/domain/ExecChannel.java new file mode 100644 index 0000000000..082fe39cf9 --- /dev/null +++ b/compute/src/main/java/org/jclouds/compute/domain/ExecChannel.java @@ -0,0 +1,96 @@ +/** + * Licensed to jclouds, Inc. (jclouds) under one or more + * contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. jclouds licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.jclouds.compute.domain; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import com.google.common.base.Supplier; +import com.google.common.io.Closeables; + +/** + * A current connection to an exec'd command. Please ensure you call {@link ExecChannel#close} + * + * @author Adrian Cole + */ +public class ExecChannel implements Closeable { + + private final OutputStream input; + private final InputStream output; + private final InputStream error; + private final Supplier exitStatus; + private final Closeable closer; + + public ExecChannel(OutputStream input, InputStream output, InputStream error, Supplier exitStatus, + Closeable closer) { + this.input = checkNotNull(input, "input"); + this.output = checkNotNull(output, "output"); + this.error = checkNotNull(error, "error"); + this.exitStatus = checkNotNull(exitStatus, "exitStatus"); + this.closer = checkNotNull(closer, "closer"); + } + + /** + * + * @return the command's {@code stdin} stream. + */ + public OutputStream getInput() { + return input; + } + + /** + * + * @return the command's {@code stderr} stream. + */ + public InputStream getError() { + return error; + } + + /** + * + * @return the command's {@code stdout} stream. + */ + public InputStream getOutput() { + return output; + } + + /** + * + * @return the exit status of the command if it was received, or {@code null} if this information + * was not received. + */ + public Supplier getExitStatus() { + return exitStatus; + } + + /** + * closes resources associated with this channel. + */ + @Override + public void close() throws IOException { + Closeables.closeQuietly(input); + Closeables.closeQuietly(output); + Closeables.closeQuietly(error); + closer.close(); + } +} \ No newline at end of file diff --git a/compute/src/main/java/org/jclouds/ssh/SshClient.java b/compute/src/main/java/org/jclouds/ssh/SshClient.java index 894b69b5e4..26f04757cd 100644 --- a/compute/src/main/java/org/jclouds/ssh/SshClient.java +++ b/compute/src/main/java/org/jclouds/ssh/SshClient.java @@ -18,6 +18,7 @@ */ package org.jclouds.ssh; +import org.jclouds.compute.domain.ExecChannel; import org.jclouds.compute.domain.ExecResponse; import org.jclouds.domain.Credentials; import org.jclouds.domain.LoginCredentials; @@ -50,9 +51,24 @@ public interface SshClient { void put(String path, Payload contents); Payload get(String path); - + + /** + * Execute a process and block until it is complete + * + * @param command command line to invoke + * @return output of the command + */ ExecResponse exec(String command); + /** + * Execute a process and allow the user to interact with it + * + * @param command command line to invoke + * @return reference to the running process + * @since 1.5.0 + */ + ExecChannel execChannel(String command); + void connect(); void disconnect(); diff --git a/drivers/jsch/src/main/java/org/jclouds/ssh/jsch/JschSshClient.java b/drivers/jsch/src/main/java/org/jclouds/ssh/jsch/JschSshClient.java index a4081c4665..f3df5612f0 100644 --- a/drivers/jsch/src/main/java/org/jclouds/ssh/jsch/JschSshClient.java +++ b/drivers/jsch/src/main/java/org/jclouds/ssh/jsch/JschSshClient.java @@ -30,6 +30,7 @@ import static org.jclouds.crypto.CryptoStreams.md5; import static org.jclouds.crypto.SshKeys.fingerprintPrivateKey; import static org.jclouds.crypto.SshKeys.sha1PrivateKey; +import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.net.ConnectException; @@ -41,6 +42,7 @@ import javax.inject.Named; import org.apache.commons.io.input.ProxyInputStream; import org.apache.commons.io.output.ByteArrayOutputStream; +import org.jclouds.compute.domain.ExecChannel; import org.jclouds.compute.domain.ExecResponse; import org.jclouds.http.handlers.BackoffLimitedRetryHandler; import org.jclouds.io.Payload; @@ -57,6 +59,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Predicate; import com.google.common.base.Predicates; import com.google.common.base.Splitter; +import com.google.common.base.Supplier; import com.google.common.io.Closeables; import com.google.inject.Inject; import com.jcraft.jsch.ChannelExec; @@ -125,7 +128,7 @@ public class JschSshClient implements SshClient { private final BackoffLimitedRetryHandler backoffLimitedRetryHandler; public JschSshClient(BackoffLimitedRetryHandler backoffLimitedRetryHandler, IPSocket socket, int timeout, - String username, String password, byte[] privateKey) { + String username, String password, byte[] privateKey) { this.host = checkNotNull(socket, "socket").getAddress(); checkArgument(socket.getPort() > 0, "ssh port must be greater then zero" + socket.getPort()); checkArgument(password != null || privateKey != null, "you must specify a password or a key"); @@ -138,10 +141,10 @@ public class JschSshClient implements SshClient { if (privateKey == null) { this.toString = String.format("%s:pw[%s]@%s:%d", username, hex(md5(password.getBytes())), host, port); } else { - String fingerPrint = fingerprintPrivateKey(new String(privateKey)); - String sha1 = sha1PrivateKey(new String(privateKey)); - this.toString = String.format("%s:rsa[fingerprint(%s),sha1(%s)]@%s:%d", username, fingerPrint, sha1, host, - port); + String fingerPrint = fingerprintPrivateKey(new String(privateKey)); + String sha1 = sha1PrivateKey(new String(privateKey)); + this.toString = String.format("%s:rsa[fingerprint(%s),sha1(%s)]@%s:%d", username, fingerPrint, sha1, host, + port); } } @@ -181,7 +184,8 @@ public class JschSshClient implements SshClient { } else { // jsch wipes out your private key if (CredentialUtils.isPrivateKeyEncrypted(privateKey)) { - throw new IllegalArgumentException("JschSshClientModule does not support private keys that require a passphrase"); + throw new IllegalArgumentException( + "JschSshClientModule does not support private keys that require a passphrase"); } jsch.addIdentity(username, Arrays.copyOf(privateKey, privateKey.length), null, emptyPassPhrase); } @@ -323,8 +327,8 @@ public class JschSshClient implements SshClient { @VisibleForTesting boolean shouldRetry(Exception from) { - Predicate predicate = retryAuth ? Predicates.or(retryPredicate, instanceOf(AuthorizationException.class)) - : retryPredicate; + Predicate predicate = retryAuth ? Predicates. or(retryPredicate, + instanceOf(AuthorizationException.class)) : retryPredicate; if (any(getCausalChain(from), predicate)) return true; if (!retryableMessages.equals("")) @@ -343,7 +347,7 @@ public class JschSshClient implements SshClient { @Override public boolean apply(Throwable arg0) { return (arg0.toString().indexOf(input) != -1) - || (arg0.getMessage() != null && arg0.getMessage().indexOf(input) != -1); + || (arg0.getMessage() != null && arg0.getMessage().indexOf(input) != -1); } }); @@ -361,7 +365,7 @@ public class JschSshClient implements SshClient { if (e.getMessage() != null && e.getMessage().indexOf("Auth fail") != -1) throw new AuthorizationException("(" + toString() + ") " + message, e); throw e instanceof SshException ? SshException.class.cast(e) : new SshException( - "(" + toString() + ") " + message, e); + "(" + toString() + ") " + message, e); } @Override @@ -467,4 +471,60 @@ public class JschSshClient implements SshClient { return this.username; } + + class ExecChannelConnection implements Connection { + private final String command; + private ChannelExec executor = null; + + ExecChannelConnection(String command) { + this.command = checkNotNull(command, "command"); + } + + @Override + public void clear() { + if (executor != null) + executor.disconnect(); + } + + @Override + public ExecChannel create() throws Exception { + checkConnected(); + String channel = "exec"; + executor = (ChannelExec) session.openChannel(channel); + executor.setPty(true); + executor.setCommand(command); + ByteArrayOutputStream error = new ByteArrayOutputStream(); + executor.setErrStream(error); + executor.connect(); + return new ExecChannel(executor.getOutputStream(), executor.getInputStream(), executor.getErrStream(), + new Supplier() { + + @Override + public Integer get() { + int exitStatus = executor.getExitStatus(); + return exitStatus != -1 ? exitStatus : null; + } + + }, new Closeable() { + + @Override + public void close() throws IOException { + clear(); + } + + }); + } + + @Override + public String toString() { + return "ExecChannel(command=[" + command + "])"; + } + }; + + + @Override + public ExecChannel execChannel(String command) { + return acquire(new ExecChannelConnection(command)); + } + } diff --git a/drivers/jsch/src/test/java/org/jclouds/ssh/jsch/JschSshClientLiveTest.java b/drivers/jsch/src/test/java/org/jclouds/ssh/jsch/JschSshClientLiveTest.java index 51b0aab5ba..5f9193058c 100644 --- a/drivers/jsch/src/test/java/org/jclouds/ssh/jsch/JschSshClientLiveTest.java +++ b/drivers/jsch/src/test/java/org/jclouds/ssh/jsch/JschSshClientLiveTest.java @@ -20,12 +20,16 @@ package org.jclouds.ssh.jsch; import static org.testng.Assert.assertEquals; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.net.InetAddress; +import org.jclouds.compute.domain.ExecChannel; import org.jclouds.compute.domain.ExecResponse; import org.jclouds.domain.LoginCredentials; import org.jclouds.io.Payload; @@ -39,6 +43,8 @@ import org.testng.annotations.BeforeGroups; import org.testng.annotations.Test; import com.google.common.base.Strings; +import com.google.common.base.Suppliers; +import com.google.common.io.Closeables; import com.google.inject.Guice; import com.google.inject.Injector; @@ -107,6 +113,22 @@ public class JschSshClientLiveTest { } + @Override + public ExecChannel execChannel(String command) { + if (command.equals("hostname")) { + return new ExecChannel(new ByteArrayOutputStream(), new ByteArrayInputStream(sshHost.getBytes()), + new ByteArrayInputStream(new byte[] {}), Suppliers.ofInstance(0), new Closeable() { + + @Override + public void close() { + + } + + }); + } + throw new RuntimeException("command " + command + " not stubbed"); + } + }; } else { Injector i = Guice.createInjector(new JschSshClientModule(), new SLF4JLoggingModule()); @@ -147,4 +169,16 @@ public class JschSshClientLiveTest { : sshHost); } + public void testExecChannelHostname() throws IOException { + ExecChannel response = setupClient().execChannel("hostname"); + try { + assertEquals(Strings2.toStringAndClose(response.getError()), ""); + assertEquals(Strings2.toStringAndClose(response.getOutput()).trim(), "localhost".equals(sshHost) ? InetAddress + .getLocalHost().getHostName() : sshHost); + } finally { + Closeables.closeQuietly(response); + } + assertEquals(response.getExitStatus().get(), new Integer(0)); + } + } \ No newline at end of file diff --git a/drivers/sshj/src/main/java/org/jclouds/sshj/SshjSshClient.java b/drivers/sshj/src/main/java/org/jclouds/sshj/SshjSshClient.java index 9916915e9d..f13b537722 100644 --- a/drivers/sshj/src/main/java/org/jclouds/sshj/SshjSshClient.java +++ b/drivers/sshj/src/main/java/org/jclouds/sshj/SshjSshClient.java @@ -30,6 +30,7 @@ import static org.jclouds.crypto.CryptoStreams.md5; import static org.jclouds.crypto.SshKeys.fingerprintPrivateKey; import static org.jclouds.crypto.SshKeys.sha1PrivateKey; +import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.net.ConnectException; @@ -46,6 +47,7 @@ import net.schmizz.sshj.common.IOUtils; import net.schmizz.sshj.connection.ConnectionException; import net.schmizz.sshj.connection.channel.direct.PTYMode; import net.schmizz.sshj.connection.channel.direct.Session; +import net.schmizz.sshj.connection.channel.direct.SessionChannel; import net.schmizz.sshj.connection.channel.direct.Session.Command; import net.schmizz.sshj.sftp.SFTPClient; import net.schmizz.sshj.sftp.SFTPException; @@ -56,6 +58,7 @@ import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile; import net.schmizz.sshj.xfer.InMemorySourceFile; import org.apache.commons.io.input.ProxyInputStream; +import org.jclouds.compute.domain.ExecChannel; import org.jclouds.compute.domain.ExecResponse; import org.jclouds.http.handlers.BackoffLimitedRetryHandler; import org.jclouds.io.Payload; @@ -71,7 +74,9 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Predicate; import com.google.common.base.Predicates; import com.google.common.base.Splitter; +import com.google.common.base.Supplier; import com.google.common.base.Throwables; +import com.google.common.io.Closeables; import com.google.inject.Inject; /** @@ -499,6 +504,59 @@ public class SshjSshClient implements SshClient { return acquire(new ExecConnection(command)); } + + class ExecChannelConnection implements Connection { + private final String command; + private SessionChannel session; + + ExecChannelConnection(String command) { + this.command = checkNotNull(command, "command"); + } + + @Override + public void clear() { + if (session != null) + Closeables.closeQuietly(session); + } + + @Override + public ExecChannel create() throws Exception { + try { + session = SessionChannel.class.cast(acquire(execConnection())); + Command output = session.exec(command); + output.join(timeoutMillis, TimeUnit.SECONDS); + return new ExecChannel(session.getOutputStream(), session.getInputStream(), session.getErrorStream(), + new Supplier() { + + @Override + public Integer get() { + return session.getExitStatus(); + } + + }, new Closeable() { + + @Override + public void close() throws IOException { + clear(); + } + + }); + } finally { + clear(); + } + } + + @Override + public String toString() { + return "ExecChannel(command=[" + command + "])"; + } + } + + @Override + public ExecChannel execChannel(String command) { + return acquire(new ExecChannelConnection(command)); + } + @Override public String getHostAddress() { return this.host; diff --git a/drivers/sshj/src/test/java/org/jclouds/sshj/SshjSshClientLiveTest.java b/drivers/sshj/src/test/java/org/jclouds/sshj/SshjSshClientLiveTest.java index 32d00abbb0..4099eb7cfd 100644 --- a/drivers/sshj/src/test/java/org/jclouds/sshj/SshjSshClientLiveTest.java +++ b/drivers/sshj/src/test/java/org/jclouds/sshj/SshjSshClientLiveTest.java @@ -20,12 +20,16 @@ package org.jclouds.sshj; import static org.testng.Assert.assertEquals; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.net.InetAddress; +import org.jclouds.compute.domain.ExecChannel; import org.jclouds.compute.domain.ExecResponse; import org.jclouds.domain.LoginCredentials; import org.jclouds.io.Payload; @@ -39,6 +43,8 @@ import org.testng.annotations.BeforeGroups; import org.testng.annotations.Test; import com.google.common.base.Strings; +import com.google.common.base.Suppliers; +import com.google.common.io.Closeables; import com.google.inject.Guice; import com.google.inject.Injector; @@ -106,7 +112,22 @@ public class SshjSshClientLiveTest { public void put(String path, String contents) { } + + @Override + public ExecChannel execChannel(String command) { + if (command.equals("hostname")) { + return new ExecChannel(new ByteArrayOutputStream(), new ByteArrayInputStream(sshHost.getBytes()), + new ByteArrayInputStream(new byte[] {}), Suppliers.ofInstance(0), new Closeable() { + @Override + public void close() { + + } + + }); + } + throw new RuntimeException("command " + command + " not stubbed"); + } }; } else { Injector i = Guice.createInjector(new SshjSshClientModule(), new SLF4JLoggingModule()); @@ -147,5 +168,16 @@ public class SshjSshClientLiveTest { assertEquals(response.getOutput().trim(), "localhost".equals(sshHost) ? InetAddress.getLocalHost().getHostName() : sshHost); } - + + public void testExecChannelHostname() throws IOException { + ExecChannel response = setupClient().execChannel("hostname"); + try { + assertEquals(Strings2.toStringAndClose(response.getError()), ""); + assertEquals(Strings2.toStringAndClose(response.getOutput()).trim(), "localhost".equals(sshHost) ? InetAddress + .getLocalHost().getHostName() : sshHost); + } finally { + Closeables.closeQuietly(response); + } + assertEquals(response.getExitStatus().get(), new Integer(0)); + } }