/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF 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.apache.oozie.action.ssh;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.List;
import java.util.concurrent.Callable;
import org.apache.oozie.client.WorkflowAction;
import org.apache.oozie.client.OozieClient;
import org.apache.oozie.client.WorkflowAction.Status;
import org.apache.oozie.action.ActionExecutor;
import org.apache.oozie.action.ActionExecutorException;
import org.apache.oozie.service.CallbackService;
import org.apache.oozie.servlet.CallbackServlet;
import org.apache.oozie.service.Services;
import org.apache.oozie.util.IOUtils;
import org.apache.oozie.util.PropertiesUtils;
import org.apache.oozie.util.XLog;
import org.apache.oozie.util.XmlUtils;
import org.jdom.Element;
import org.jdom.JDOMException;
import org.jdom.Namespace;
/**
* Ssh action executor. <p/> <ul> <li>Execute the shell commands on the remote host</li> <li>Copies the base and wrapper
* scripts on to the remote location</li> <li>Base script is used to run the command on the remote host</li> <li>Wrapper
* script is used to check the status of the submitted command</li> <li>handles the submission failures</li> </ul>
*/
public class SshActionExecutor extends ActionExecutor {
public static final String ACTION_TYPE = "ssh";
/**
* Configuration parameter which specifies whether the specified ssh user is allowed, or has to be the job user.
*/
public static final String CONF_SSH_ALLOW_USER_AT_HOST = CONF_PREFIX + "ssh.allow.user.at.host";
protected static final String SSH_COMMAND_OPTIONS =
"-o PasswordAuthentication=no -o KbdInteractiveDevices=no -o StrictHostKeyChecking=no -o ConnectTimeout=20 ";
protected static final String SSH_COMMAND_BASE = "ssh " + SSH_COMMAND_OPTIONS;
protected static final String SCP_COMMAND_BASE = "scp " + SSH_COMMAND_OPTIONS;
public static final String ERR_SETUP_FAILED = "SETUP_FAILED";
public static final String ERR_EXECUTION_FAILED = "EXECUTION_FAILED";
public static final String ERR_UNKNOWN_ERROR = "UNKOWN_ERROR";
public static final String ERR_COULD_NOT_CONNECT = "COULD_NOT_CONNECT";
public static final String ERR_HOST_RESOLUTION = "COULD_NOT_RESOLVE_HOST";
public static final String ERR_FNF = "FNF";
public static final String ERR_AUTH_FAILED = "AUTH_FAILED";
public static final String ERR_NO_EXEC_PERM = "NO_EXEC_PERM";
public static final String ERR_USER_MISMATCH = "ERR_USER_MISMATCH";
public static final String ERR_EXCEDE_LEN = "ERR_OUTPUT_EXCEED_MAX_LEN";
public static final String DELETE_TMP_DIR = "oozie.action.ssh.delete.remote.tmp.dir";
public static final String HTTP_COMMAND = "oozie.action.ssh.http.command";
public static final String HTTP_COMMAND_OPTIONS = "oozie.action.ssh.http.command.post.options";
private static final String EXT_STATUS_VAR = "#status";
private static int maxLen;
private static boolean allowSshUserAtHost;
protected SshActionExecutor() {
super(ACTION_TYPE);
}
/**
* Initialize Action.
*/
@Override
public void initActionType() {
super.initActionType();
maxLen = getOozieConf().getInt(CallbackServlet.CONF_MAX_DATA_LEN, 2 * 1024);
allowSshUserAtHost = getOozieConf().getBoolean(CONF_SSH_ALLOW_USER_AT_HOST, true);
registerError(InterruptedException.class.getName(), ActionExecutorException.ErrorType.ERROR, "SH001");
registerError(JDOMException.class.getName(), ActionExecutorException.ErrorType.ERROR, "SH002");
initSshScripts();
}
/**
* Check ssh action status.
*
* @param context action execution context.
* @param action action object.
*/
@Override
public void check(Context context, WorkflowAction action) throws ActionExecutorException {
Status status = getActionStatus(context, action);
boolean captureOutput = false;
try {
Element eConf = XmlUtils.parseXml(action.getConf());
Namespace ns = eConf.getNamespace();
captureOutput = eConf.getChild("capture-output", ns) != null;
}
catch (JDOMException ex) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.ERROR, "ERR_XML_PARSE_FAILED",
"unknown error", ex);
}
XLog log = XLog.getLog(getClass());
log.debug("Capture Output: {0}", captureOutput);
if (status == Status.OK) {
if (captureOutput) {
String outFile = getRemoteFileName(context, action, "stdout", false, true);
String dataCommand = SSH_COMMAND_BASE + action.getTrackerUri() + " cat " + outFile;
log.debug("Ssh command [{0}]", dataCommand);
try {
Process process = Runtime.getRuntime().exec(dataCommand.split("\\s"));
StringBuffer buffer = new StringBuffer();
boolean overflow = false;
drainBuffers(process, buffer, null, maxLen);
if (buffer.length() > maxLen) {
overflow = true;
}
if (overflow) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.ERROR,
"ERR_OUTPUT_EXCEED_MAX_LEN", "unknown error");
}
context.setExecutionData(status.toString(), PropertiesUtils.stringToProperties(buffer.toString()));
}
catch (Exception ex) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.ERROR, "ERR_UNKNOWN_ERROR",
"unknown error", ex);
}
}
else {
context.setExecutionData(status.toString(), null);
}
}
else {
if (status == Status.ERROR) {
context.setExecutionData(status.toString(), null);
}
else {
context.setExternalStatus(status.toString());
}
}
}
/**
* Kill ssh action.
*
* @param context action execution context.
* @param action object.
*/
@Override
public void kill(Context context, WorkflowAction action) throws ActionExecutorException {
String command = "ssh " + action.getTrackerUri() + " kill -KILL " + action.getExternalId();
int returnValue = getReturnValue(command);
if (returnValue != 0) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.ERROR, "FAILED_TO_KILL", XLog.format(
"Unable to kill process {0} on {1}", action.getExternalId(), action.getTrackerUri()));
}
context.setEndData(WorkflowAction.Status.KILLED, "ERROR");
}
/**
* Start the ssh action execution.
*
* @param context action execution context.
* @param action action object.
*/
@SuppressWarnings("unchecked")
@Override
public void start(final Context context, final WorkflowAction action) throws ActionExecutorException {
XLog log = XLog.getLog(getClass());
log.info("start() begins");
String confStr = action.getConf();
Element conf;
try {
conf = XmlUtils.parseXml(confStr);
}
catch (Exception ex) {
throw convertException(ex);
}
Namespace nameSpace = conf.getNamespace();
Element hostElement = conf.getChild("host", nameSpace);
String hostString = hostElement.getValue().trim();
hostString = prepareUserHost(hostString, context);
final String host = hostString;
final String dirLocation = execute(new Callable<String>() {
public String call() throws Exception {
return setupRemote(host, context, action);
}
});
String runningPid = execute(new Callable<String>() {
public String call() throws Exception {
return checkIfRunning(host, context, action);
}
});
String pid = "";
if (runningPid == null) {
final Element commandElement = conf.getChild("command", nameSpace);
final boolean ignoreOutput = conf.getChild("capture-output", nameSpace) == null;
if (commandElement != null) {
List<Element> argsList = conf.getChildren("args", nameSpace);
StringBuilder args = new StringBuilder("");
if ((argsList != null) && (argsList.size() > 0)) {
for (Element argsElement : argsList) {
args = args.append(argsElement.getValue()).append(" ");
}
args.setLength(args.length() - 1);
}
final String argsString = args.toString();
final String recoveryId = context.getRecoveryId();
pid = execute(new Callable<String>() {
@Override
public String call() throws Exception {
return doExecute(host, dirLocation, commandElement.getValue(), argsString, ignoreOutput,
action, recoveryId);
}
});
}
context.setStartData(pid, host, host);
}
else {
pid = runningPid;
context.setStartData(pid, host, host);
check(context, action);
}
log.info("start() ends");
}
private String checkIfRunning(String host, final Context context, final WorkflowAction action) {
String pid = null;
String outFile = getRemoteFileName(context, action, "pid", false, false);
String getOutputCmd = SSH_COMMAND_BASE + host + " cat " + outFile;
try {
Process process = Runtime.getRuntime().exec(getOutputCmd.split("\\s"));
StringBuffer buffer = new StringBuffer();
drainBuffers(process, buffer, null, maxLen);
pid = getFirstLine(buffer);
if (Long.valueOf(pid) > 0) {
return pid;
}
else {
return null;
}
}
catch (Exception e) {
return null;
}
}
/**
* Get remote host working location.
*
* @param context action execution context
* @param action Action
* @param fileExtension Extension to be added to file name
* @param dirOnly Get the Directory only
* @param useExtId Flag to use external ID in the path
* @return remote host file name/Directory.
*/
public String getRemoteFileName(Context context, WorkflowAction action, String fileExtension, boolean dirOnly,
boolean useExtId) {
String path = getActionDirPath(context.getWorkflow().getId(), action, ACTION_TYPE, false) + "/";
if (dirOnly) {
return path;
}
if (useExtId) {
path = path + action.getExternalId() + ".";
}
path = path + context.getRecoveryId() + "." + fileExtension;
return path;
}
/**
* Utility method to execute command.
*
* @param command Command to execute as String.
* @return exit status of the execution.
* @throws IOException if process exits with status nonzero.
* @throws InterruptedException if process does not run properly.
*/
public int executeCommand(String command) throws IOException, InterruptedException {
Runtime runtime = Runtime.getRuntime();
Process p = runtime.exec(command.split("\\s"));
StringBuffer errorBuffer = new StringBuffer();
int exitValue = drainBuffers(p, null, errorBuffer, maxLen);
String error = null;
if (exitValue != 0) {
error = getTruncatedString(errorBuffer);
throw new IOException(XLog.format("Not able to perform operation [{0}]", command) + " | " + "ErrorStream: "
+ error);
}
return exitValue;
}
/**
* Do ssh action execution setup on remote host.
*
* @param host host name.
* @param context action execution context.
* @param action action object.
* @return remote host working directory.
* @throws IOException thrown if failed to setup.
* @throws InterruptedException thrown if any interruption happens.
*/
protected String setupRemote(String host, Context context, WorkflowAction action) throws IOException, InterruptedException {
XLog log = XLog.getLog(getClass());
log.info("Attempting to copy ssh base scripts to remote host [{0}]", host);
String localDirLocation = Services.get().getRuntimeDir() + "/ssh";
if (localDirLocation.endsWith("/")) {
localDirLocation = localDirLocation.substring(0, localDirLocation.length() - 1);
}
File file = new File(localDirLocation + "/ssh-base.sh");
if (!file.exists()) {
throw new IOException("Required Local file " + file.getAbsolutePath() + " not present.");
}
file = new File(localDirLocation + "/ssh-wrapper.sh");
if (!file.exists()) {
throw new IOException("Required Local file " + file.getAbsolutePath() + " not present.");
}
String remoteDirLocation = getRemoteFileName(context, action, null, true, true);
String command = XLog.format("{0}{1} mkdir -p {2} ", SSH_COMMAND_BASE, host, remoteDirLocation).toString();
executeCommand(command);
command = XLog.format("{0}{1}/ssh-base.sh {2}/ssh-wrapper.sh {3}:{4}", SCP_COMMAND_BASE, localDirLocation,
localDirLocation, host, remoteDirLocation);
executeCommand(command);
command = XLog.format("{0}{1} chmod +x {2}ssh-base.sh {3}ssh-wrapper.sh ", SSH_COMMAND_BASE, host,
remoteDirLocation, remoteDirLocation);
executeCommand(command);
return remoteDirLocation;
}
/**
* Execute the ssh command.
*
* @param host hostname.
* @param dirLocation location of the base and wrapper scripts.
* @param cmnd command to be executed.
* @param args command arguments.
* @param ignoreOutput ignore output option.
* @param action action object.
* @param recoveryId action id + run number to enable recovery in rerun
* @return process id of the running command.
* @throws IOException thrown if failed to run the command.
* @throws InterruptedException thrown if any interruption happens.
*/
protected String doExecute(String host, String dirLocation, String cmnd, String args, boolean ignoreOutput,
WorkflowAction action, String recoveryId) throws IOException, InterruptedException {
XLog log = XLog.getLog(getClass());
Runtime runtime = Runtime.getRuntime();
String callbackPost = ignoreOutput ? "_" : getOozieConf().get(HTTP_COMMAND_OPTIONS).replace(" ", "%%%");
// TODO check
String callBackUrl = Services.get().get(CallbackService.class)
.createCallBackUrl(action.getId(), EXT_STATUS_VAR);
String command = XLog.format("{0}{1} {2}ssh-base.sh {3} \"{4}\" \"{5}\" {6} {7} {8} ", SSH_COMMAND_BASE, host,
dirLocation, getOozieConf().get(HTTP_COMMAND), callBackUrl, callbackPost, recoveryId, cmnd, args)
.toString();
log.trace("Executing ssh command [{0}]", command);
Process p = runtime.exec(command.split("\\s"));
String pid = "";
StringBuffer inputBuffer = new StringBuffer();
StringBuffer errorBuffer = new StringBuffer();
int exitValue = drainBuffers(p, inputBuffer, errorBuffer, maxLen);
pid = getFirstLine(inputBuffer);
String error = null;
if (exitValue != 0) {
error = getTruncatedString(errorBuffer);
throw new IOException(XLog.format("Not able to execute ssh-base.sh on {0}", host) + " | " + "ErrorStream: "
+ error);
}
return pid;
}
/**
* End action execution.
*
* @param context action execution context.
* @param action action object.
* @throws ActionExecutorException thrown if action end execution fails.
*/
public void end(final Context context, final WorkflowAction action) throws ActionExecutorException {
if (action.getExternalStatus().equals("OK")) {
context.setEndData(WorkflowAction.Status.OK, WorkflowAction.Status.OK.toString());
}
else {
context.setEndData(WorkflowAction.Status.ERROR, WorkflowAction.Status.ERROR.toString());
}
boolean deleteTmpDir = getOozieConf().getBoolean(DELETE_TMP_DIR, true);
if (deleteTmpDir) {
String tmpDir = getRemoteFileName(context, action, null, true, false);
String removeTmpDirCmd = SSH_COMMAND_BASE + action.getTrackerUri() + " rm -rf " + tmpDir;
int retVal = getReturnValue(removeTmpDirCmd);
if (retVal != 0) {
XLog.getLog(getClass()).warn("Cannot delete temp dir {0}", tmpDir);
}
}
}
/**
* Get the return value of a process.
*
* @param command command to be executed.
* @return zero if execution is successful and any non zero value for failure.
* @throws ActionExecutorException
*/
private int getReturnValue(String command) throws ActionExecutorException {
int returnValue;
Process ps = null;
try {
ps = Runtime.getRuntime().exec(command.split("\\s"));
returnValue = drainBuffers(ps, null, null, 0);
}
catch (IOException e) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.ERROR, "FAILED_OPERATION", XLog.format(
"Not able to perform operation {0}", command), e);
}
finally {
ps.destroy();
}
return returnValue;
}
/**
* Copy the ssh base and wrapper scripts to the local directory.
*/
private void initSshScripts() {
String dirLocation = Services.get().getRuntimeDir() + "/ssh";
File path = new File(dirLocation);
if (!path.mkdirs()) {
throw new RuntimeException(XLog.format("Not able to create required directory {0}", dirLocation));
}
try {
IOUtils.copyCharStream(IOUtils.getResourceAsReader("ssh-base.sh", -1), new FileWriter(dirLocation
+ "/ssh-base.sh"));
IOUtils.copyCharStream(IOUtils.getResourceAsReader("ssh-wrapper.sh", -1), new FileWriter(dirLocation
+ "/ssh-wrapper.sh"));
}
catch (IOException ie) {
throw new RuntimeException(XLog.format("Not able to copy required scripts file to {0} "
+ "for SshActionHandler", dirLocation));
}
}
/**
* Get action status.
*
* @param action action object.
* @return status of the action(RUNNING/OK/ERROR).
* @throws ActionExecutorException thrown if there is any error in getting status.
*/
protected Status getActionStatus(Context context, WorkflowAction action) throws ActionExecutorException {
String command = SSH_COMMAND_BASE + action.getTrackerUri() + " ps -p " + action.getExternalId();
Status aStatus;
int returnValue = getReturnValue(command);
if (returnValue == 0) {
aStatus = Status.RUNNING;
}
else {
String outFile = getRemoteFileName(context, action, "error", false, true);
String checkErrorCmd = SSH_COMMAND_BASE + action.getTrackerUri() + " ls " + outFile;
int retVal = getReturnValue(checkErrorCmd);
if (retVal == 0) {
aStatus = Status.ERROR;
}
else {
aStatus = Status.OK;
}
}
return aStatus;
}
/**
* Execute the callable.
*
* @param callable required callable.
* @throws ActionExecutorException thrown if there is any error in command execution.
*/
private <T> T execute(Callable<T> callable) throws ActionExecutorException {
XLog log = XLog.getLog(getClass());
try {
return callable.call();
}
catch (IOException ex) {
log.warn("Error while executing ssh EXECUTION");
String errorMessage = ex.getMessage();
if (null == errorMessage) { // Unknown IOException
throw new ActionExecutorException(ActionExecutorException.ErrorType.ERROR, ERR_UNKNOWN_ERROR, ex
.getMessage(), ex);
} // Host Resolution Issues
else {
if (errorMessage.contains("Could not resolve hostname") ||
errorMessage.contains("service not known")) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.TRANSIENT, ERR_HOST_RESOLUTION, ex
.getMessage(), ex);
} // Connection Timeout. Host temporarily down.
else {
if (errorMessage.contains("timed out")) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.TRANSIENT, ERR_COULD_NOT_CONNECT,
ex.getMessage(), ex);
}// Local ssh-base or ssh-wrapper missing
else {
if (errorMessage.contains("Required Local file")) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.TRANSIENT, ERR_FNF,
ex.getMessage(), ex); // local_FNF
}// Required oozie bash scripts missing, after the copy was
// successful
else {
if (errorMessage.contains("No such file or directory")
&& (errorMessage.contains("ssh-base") || errorMessage.contains("ssh-wrapper"))) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.TRANSIENT, ERR_FNF,
ex.getMessage(), ex); // remote
// FNF
} // Required application execution binary missing (either
// caught by ssh-wrapper
else {
if (errorMessage.contains("command not found")) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.NON_TRANSIENT, ERR_FNF, ex
.getMessage(), ex); // remote
// FNF
} // Permission denied while connecting
else {
if (errorMessage.contains("Permission denied")) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.NON_TRANSIENT, ERR_AUTH_FAILED, ex
.getMessage(), ex);
} // Permission denied while executing
else {
if (errorMessage.contains(": Permission denied")) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.NON_TRANSIENT, ERR_NO_EXEC_PERM, ex
.getMessage(), ex);
}
else {
throw new ActionExecutorException(ActionExecutorException.ErrorType.ERROR, ERR_UNKNOWN_ERROR, ex
.getMessage(), ex);
}
}
}
}
}
}
}
}
} // Any other type of exception
catch (Exception ex) {
throw convertException(ex);
}
}
/**
* Checks whether the system is configured to always use the oozie user for ssh, and injects the user if required.
*
* @param host the host string.
* @param context the execution context.
* @return the modified host string with a user parameter added on if required.
* @throws ActionExecutorException in case the flag to use the oozie user is turned on and there is a mismatch
* between the user specified in the host and the oozie user.
*/
private String prepareUserHost(String host, Context context) throws ActionExecutorException {
String oozieUser = context.getProtoActionConf().get(OozieClient.USER_NAME);
if (allowSshUserAtHost) {
if (!host.contains("@")) {
host = oozieUser + "@" + host;
}
}
else {
if (host.contains("@")) {
if (!host.toLowerCase().startsWith(oozieUser + "@")) {
throw new ActionExecutorException(ActionExecutorException.ErrorType.ERROR, ERR_USER_MISMATCH,
XLog.format("user mismatch between oozie user [{0}] and ssh host [{1}]", oozieUser, host));
}
}
else {
host = oozieUser + "@" + host;
}
}
return host;
}
public boolean isCompleted(String externalStatus) {
return true;
}
/**
* Truncate the string to max length.
*
* @param strBuffer
* @return truncated string string
*/
private String getTruncatedString(StringBuffer strBuffer) {
if (strBuffer.length() <= maxLen) {
return strBuffer.toString();
}
else {
return strBuffer.substring(0, maxLen);
}
}
/**
* Drains the inputStream and errorStream of the Process being executed. The contents of the streams are stored if a
* buffer is provided for the stream.
*
* @param p The Process instance.
* @param inputBuffer The buffer into which STDOUT is to be read. Can be null if only draining is required.
* @param errorBuffer The buffer into which STDERR is to be read. Can be null if only draining is required.
* @param maxLength The maximum data length to be stored in these buffers. This is an indicative value, and the
* store content may exceed this length.
* @return the exit value of the process.
* @throws IOException
*/
private int drainBuffers(Process p, StringBuffer inputBuffer, StringBuffer errorBuffer, int maxLength)
throws IOException {
int exitValue = -1;
BufferedReader ir = new BufferedReader(new InputStreamReader(p.getInputStream()));
BufferedReader er = new BufferedReader(new InputStreamReader(p.getErrorStream()));
int inBytesRead = 0;
int errBytesRead = 0;
boolean processEnded = false;
try {
while (!processEnded) {
try {
exitValue = p.exitValue();
processEnded = true;
}
catch (IllegalThreadStateException ex) {
// Continue to drain.
}
inBytesRead += drainBuffer(ir, inputBuffer, maxLength, inBytesRead, processEnded);
errBytesRead += drainBuffer(er, errorBuffer, maxLength, errBytesRead, processEnded);
}
}
finally {
ir.close();
er.close();
}
return exitValue;
}
/**
* Reads the contents of a stream and stores them into the provided buffer.
*
* @param br The stream to be read.
* @param storageBuf The buffer into which the contents of the stream are to be stored.
* @param maxLength The maximum number of bytes to be stored in the buffer. An indicative value and may be
* exceeded.
* @param bytesRead The number of bytes read from this stream to date.
* @param readAll If true, the stream is drained while their is data available in it. Otherwise, only a single chunk
* of data is read, irrespective of how much is available.
* @return
* @throws IOException
*/
private int drainBuffer(BufferedReader br, StringBuffer storageBuf, int maxLength, int bytesRead, boolean readAll)
throws IOException {
int bReadSession = 0;
if (br.ready()) {
char[] buf = new char[1024];
do {
int bReadCurrent = br.read(buf, 0, 1024);
if (storageBuf != null && bytesRead < maxLength) {
storageBuf.append(buf, 0, bReadCurrent);
}
bReadSession += bReadCurrent;
} while (br.ready() && readAll);
}
return bReadSession;
}
/**
* Returns the first line from a StringBuffer, recognized by the new line character \n.
*
* @param buffer The StringBuffer from which the first line is required.
* @return The first line of the buffer.
*/
private String getFirstLine(StringBuffer buffer) {
int newLineIndex = 0;
newLineIndex = buffer.indexOf("\n");
if (newLineIndex == -1) {
return buffer.toString();
}
else {
return buffer.substring(0, newLineIndex);
}
}
}