package org.oddjob.jobs; import java.io.BufferedInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.OutputStream; import java.util.HashMap; import java.util.Map; import org.oddjob.Stoppable; import org.oddjob.arooa.deploy.annotations.ArooaAttribute; import org.oddjob.arooa.deploy.annotations.ArooaText; import org.oddjob.arooa.utils.ArooaTokenizer; import org.oddjob.arooa.utils.QuoteTokenizerFactory; import org.oddjob.framework.SerializableJob; import org.oddjob.logging.ConsoleOwner; import org.oddjob.logging.LogArchive; import org.oddjob.logging.LogArchiver; import org.oddjob.logging.LogLevel; import org.oddjob.logging.LoggingOutputStream; import org.oddjob.logging.cache.LogArchiveImpl; import org.oddjob.util.IO; import org.oddjob.util.OddjobConfigException; /** * @oddjob.description Execute an external program. This job will * flag complete if the return state of the external program is 0, * otherwise it will flag not complete. * <p> * Processes may behave differently on different operating systems - for * instance stop doesn't always kill the process. Please see * <a href="http://bugs.sun.com/bugdatabase/view_bug.do;:YfiG?bug_id=4109888"> * http://bugs.sun.com/bugdatabase/view_bug.do;:YfiG?bug_id=4109888</a> * for additional information. * * @oddjob.example * * A simple example. * * {@oddjob.xml.resource org/oddjob/jobs/ExecSimpleExample2.xml} * * Oddjob will treat arguments in quotes as single program argument and allows * them to be escaped with backslash. If this is too confusing it is sometimes * easier to specify the command as individual arguments. The * above is equivalent to: * * {@oddjob.xml.resource org/oddjob/jobs/ExecSimpleExample.xml} * * @oddjob.example * * Using the existing environment with an additional environment variable. * * {@oddjob.xml.resource org/oddjob/jobs/ExecJobEnvironmentExample.xml} * * @oddjob.example * * Capturing console output to a file. The output is Oddjob's command * line help. * * {@oddjob.xml.resource org/oddjob/jobs/ExecWithRedirectToFile.xml} * * @oddjob.example * * Capturing console output to the logger. Note how the logger output * can be defined with different log levels for stdout and sterr. * * {@oddjob.xml.resource org/oddjob/jobs/ExecWithRedirectToLog.xml} * * @oddjob.example * * Using the output of one process as the input of another. Standard input for * the first process is provided by a buffer. A second buffer captures the * output of that process and passess it to the second process. The output * of the second process is captured and sent to the console of the parent * process. * * {@oddjob.xml.resource org/oddjob/jobs/ExecWithStdInExample.xml} * * @author Rob Gordon. */ public class ExecJob extends SerializableJob implements Stoppable, ConsoleOwner { private static final long serialVersionUID = 2009012700L; private static int consoleCount; private static String uniqueConsoleId() { synchronized (ExecJob.class) { return ("EXEC_CONSOLE" + consoleCount++); } } private transient LogArchiveImpl consoleArchive; /** * Complete construction. */ private void completeConstruction() { consoleArchive = new LogArchiveImpl( uniqueConsoleId(), LogArchiver.MAX_HISTORY); this.environment = new HashMap<String, String>(); } /** * @oddjob.property * @oddjob.description The working directory. * @oddjob.required No */ private File dir; /** * @oddjob.property * @oddjob.description The command to execute. The command is * interpreted as space delimited text which may * be specified over several lines. Arguments that need to * include spaces must be quoted. Within quoted arguments quotes * may be escaped using a backslash. * @oddjob.required yes, unless args are * provided instead. */ private String command; /** * @oddjob.property * @oddjob.description A string list of arguments. * @oddjob.required No. */ private String[] args; private boolean newEnvironment; /** * @oddjob.property environment * @oddjob.description An environment variable to be * set before the program is executed. This is a * {@link MapType} like property. * @oddjob.required No. */ private Map<String, String> environment; private boolean redirectStderr; /** * @oddjob.property * @oddjob.description An input stream which will * act as stdin for the process. * @oddjob.required No. */ private transient InputStream stdin; /** * @oddjob.property * @oddjob.description An output to where stdout * for the process will be written. * @oddjob.required No. */ private transient OutputStream stdout; /** * @oddjob.property * @oddjob.description An output to where stderr * of the proces will be written. * @oddjob.required No. */ private transient OutputStream stderr; /** * The process. */ private transient volatile Process proc; private transient volatile Thread thread; /** * @oddjob.property * @oddjob.description The exit value of the process. */ private int exitValue; public ExecJob() { completeConstruction(); } /** * Add an argument. * * @param arg The argument. */ public void setArgs(String[] args) { this.args = args; } /** * Set the command to run. * * @param command The command. */ @ArooaText public void setCommand(String command) { this.command = command; } /** * Get the command. * * @return The command. */ public String getCommand() { return command; } /** * Set the working directory. * * @param dir The working directory. */ @ArooaAttribute public void setDir(File dir) { this.dir = dir; } /** * @oddjob.property newEnvironment * @oddjob.description Create a fresh/clean environment. * @oddjob.required No. */ public void setNewEnvironment(boolean explicitEnvironment) { this.newEnvironment = explicitEnvironment; } public boolean isNewEnvironment() { return newEnvironment; } /** * Add an environment variable. * * @param nvp The name/value pair variable. */ public void setEnvironment(String name, String value) { if (value == null) { this.environment.remove(name); } else { this.environment.put(name, value); } } public String getEnvironment(String name) { return this.environment.get(name); } /** * @oddjob.property redirectStderr * @oddjob.description Redirect the standard error stream in * standard output. * @oddjob.required No. */ public void setRedirectStderr(boolean redirectErrorStream) { this.redirectStderr = redirectErrorStream; } public boolean isRedirectStderr() { return this.redirectStderr; } /** * Set the input stream stdin for the process will * be read from. * * @param stdin An InputStream. */ public void setStdin(InputStream stdin) { this.stdin = stdin; } /** * Get the input stream for stdin. This will be null unless one has * been provided. * * @return An InputStream or null. */ public InputStream getStdin() { return stdin; } /** * Set the output stream stdout from the process will * be directed to. * * @param stdout The output stream. */ public void setStdout(OutputStream stdout) { this.stdout = stdout; } /** * Get the output stream for stdout. This will be null unless one has * been provided. * * @return An OutputStream or null. */ public OutputStream getStdout() { return stdout; } /** * Set the output stream stderr from the process will * be directed to. * * @param stderr The error stream. */ public void setStderr(OutputStream stderr) { this.stderr = stderr; } /** * Get the output stream for stderr. This will be null unless one has * been provided. * * @return An OutputStream or null. */ public OutputStream getStderr() { return stderr; } /** * Provide the {@link ArooaTokenizer} to use for parsing commands. * * @return A tokenizer, never null. */ public ArooaTokenizer commandTokenizer() { return new QuoteTokenizerFactory( "\\s+", '"', '\\').newTokenizer(); } /* * (non-Javadoc) * @see org.oddjob.jobs.AbstractJob#execute() */ protected int execute() throws Exception { ProcessBuilder processBuilder; String[] theArgs = args; if (theArgs == null && command != null) { theArgs = commandTokenizer().parse(command.trim()); logger().info("Command: " + command); } if (theArgs == null || theArgs.length == 0) { throw new OddjobConfigException("No command given."); } logger().info("Args: " + displayArgs(theArgs)); processBuilder = new ProcessBuilder(theArgs); if (dir == null) { dir = processBuilder.directory(); } else { processBuilder.directory(dir); } Map<String, String> env = processBuilder.environment(); if (newEnvironment) { env.clear(); } if (environment != null) { for (Map.Entry<String, String> entry: environment.entrySet()) { env.put(entry.getKey(), entry.getValue()); } } processBuilder.redirectErrorStream(redirectStderr); proc = processBuilder.start(); Thread outThread = new CopyStream("stdout", proc.getInputStream(), stdout); outThread.start(); Thread errThread = null; if (!redirectStderr) { errThread = new CopyStream("stderr", proc.getErrorStream(), stderr); errThread.start(); } OutputStream processStdIn = proc.getOutputStream(); // copy input. if (stdin != null) { IO.copy(stdin, processStdIn); stdin.close(); } processStdIn.close(); thread = Thread.currentThread(); try { logger().debug("Waiting for process."); exitValue = proc.waitFor(); logger().debug("Process completed with exit value " + exitValue); } finally { thread = null; if (errThread != null) { errThread.join(); } outThread.join(); // A destroy is required even if the process has terminated // because otherwise file descriptors are left open. // This must happen after the stream readers have finished // otherwise it causes Stream Closed exceptions. proc.destroy(); synchronized (this) { // wake up the stop wait. notifyAll(); } } return exitValue; } private class CopyStream extends Thread { private final String name; private final InputStream stream; private final OutputStream to; public CopyStream(String name, InputStream stream, OutputStream to) { this.name = name; this.stream = new BufferedInputStream(stream); this.to = to; } public void run() { OutputStream os = new LoggingOutputStream(to, LogLevel.ERROR, consoleArchive); try { IO.copy(stream, os); } catch (IOException e) { // Check process hasn't been destroyed. If it has then // there could be an intermittent java.io.IOException: Bad file descriptor // which might be related to this issue: // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=5101298. if (!stop) { logger().error("Failed copying process " + name + ".", e); } } finally { try { os.close(); } catch (IOException e) { logger().error("Failed closing os for " + name + ".", e); } try { stream.close(); } catch (IOException e) { logger().error("Failed closing process os for " + name + ".", e); } } } } /* * (non-Javadoc) * @see org.oddjob.framework.BaseComponent#onStop() */ public void onStop() { Process proc = this.proc; if (proc == null) { return; } proc.destroy(); for (int i = 0; i < 3 && thread != null; ++i) { synchronized (this) { try { logger().debug("Waiting for process to die."); wait(1000); } catch (InterruptedException e) { return; } } } Thread thread = this.thread; if (thread != null) { logger().warn("Process failed to die - needs to be manually killed."); thread.interrupt(); } } /** * @return Returns the dir. */ public File getDir() { return dir; } public int getExitValue() { return exitValue; } public LogArchive consoleLog() { return consoleArchive; } /* * Custome serialization. */ private void writeObject(ObjectOutputStream s) throws IOException { s.defaultWriteObject(); } /* * Custome serialization. */ private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { s.defaultReadObject(); completeConstruction(); } private static String displayArgs(String[] args) { StringBuilder builder = new StringBuilder(); for (String arg: args) { if (builder.length() > 0) { builder.append(' '); } builder.append('['); builder.append(arg); builder.append(']'); } return builder.toString(); } }