/* * 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.brooklyn.util.core.internal.ssh; import static com.google.common.base.Preconditions.checkArgument; import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Map.Entry; import org.apache.brooklyn.config.ConfigKey; import org.apache.brooklyn.util.collections.MutableList; import org.apache.brooklyn.util.core.flags.TypeCoercions; import org.apache.brooklyn.util.os.Os; import org.apache.brooklyn.util.ssh.BashCommands; import org.apache.brooklyn.util.text.Identifiers; import org.apache.brooklyn.util.text.Strings; import org.apache.brooklyn.util.text.StringEscapes.BashStringEscapes; import org.apache.brooklyn.util.time.Duration; import org.apache.brooklyn.util.time.Time; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.Joiner; import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; public abstract class ShellAbstractTool implements ShellTool { private static final Logger LOG = LoggerFactory.getLogger(ShellAbstractTool.class); protected final File localTempDir; public ShellAbstractTool(String localTempDir) { this(localTempDir == null ? null : new File(Os.tidyPath(localTempDir))); } public ShellAbstractTool(File localTempDir) { if (localTempDir == null) { localTempDir = new File(Os.tmp(), "tmpssh-"+Os.user()); if (!localTempDir.exists()) localTempDir.mkdir(); Os.deleteOnExitEmptyParentsUpTo(localTempDir, new File(Os.tmp())); } this.localTempDir = localTempDir; } public ShellAbstractTool() { this((File)null); } protected static void warnOnDeprecated(Map<String, ?> props, String deprecatedKey, String correctKey) { if (props.containsKey(deprecatedKey)) { if (correctKey != null && props.containsKey(correctKey)) { Object dv = props.get(deprecatedKey); Object cv = props.get(correctKey); if (!Objects.equal(cv, dv)) { LOG.warn("SshTool detected deprecated key '"+deprecatedKey+"' with different value ("+dv+") "+ "than new key '"+correctKey+"' ("+cv+"); ambiguous which will be used"); } else { // ignore, the deprecated key populated for legacy reasons } } else { Object dv = props.get(deprecatedKey); LOG.warn("SshTool detected deprecated key '"+deprecatedKey+"' used, with value ("+dv+")"); } } } protected static Boolean hasVal(Map<String,?> map, ConfigKey<?> keyC) { String key = keyC.getName(); return map.containsKey(key); } protected static <T> T getMandatoryVal(Map<String,?> map, ConfigKey<T> keyC) { String key = keyC.getName(); checkArgument(map.containsKey(key), "must contain key '"+keyC+"'"); return TypeCoercions.coerce(map.get(key), keyC.getTypeToken()); } public static <T> T getOptionalVal(Map<String,?> map, ConfigKey<T> keyC) { if (keyC==null) return null; String key = keyC.getName(); if (map!=null && map.containsKey(key) && map.get(key) != null) { return TypeCoercions.coerce(map.get(key), keyC.getTypeToken()); } else { return keyC.getDefaultValue(); } } /** returns the value of the key if specified, otherwise defaultValue */ protected static <T> T getOptionalVal(Map<String,?> map, ConfigKey<T> keyC, T defaultValue) { String key = keyC.getName(); if (map!=null && map.containsKey(key) && map.get(key) != null) { return TypeCoercions.coerce(map.get(key), keyC.getTypeToken()); } else { return defaultValue; } } protected void closeWhispering(Closeable closeable, Object context) { closeWhispering(closeable, this, context); } /** * Similar to Guava's Closeables.closeQuitely, except logs exception at debug with context in message. */ protected static void closeWhispering(Closeable closeable, Object context1, Object context2) { if (closeable != null) { try { closeable.close(); } catch (IOException e) { if (LOG.isDebugEnabled()) { String msg = String.format("<< exception during close, for %s -> %s (%s); continuing.", context1, context2, closeable); if (LOG.isTraceEnabled()) LOG.debug(msg + ": " + e); else LOG.trace(msg, e); } } } } protected File writeTempFile(InputStream contents) { File tempFile = Os.writeToTempFile(contents, localTempDir, "sshcopy", "data"); tempFile.setReadable(false, false); tempFile.setReadable(true, true); tempFile.setWritable(false); tempFile.setExecutable(false); return tempFile; } protected File writeTempFile(String contents) { return writeTempFile(contents.getBytes()); } protected File writeTempFile(byte[] contents) { return writeTempFile(new ByteArrayInputStream(contents)); } protected String toScript(Map<String,?> props, List<String> commands, Map<String,?> env) { List<String> allcmds = toCommandSequence(commands, env); StringBuilder result = new StringBuilder(); result.append(getOptionalVal(props, PROP_SCRIPT_HEADER)).append('\n'); for (String cmd : allcmds) { result.append(cmd).append('\n'); } return result.toString(); } /** * Merges the commands and env, into a single set of commands. Also escapes the commands as required. * * Not all ssh servers handle "env", so instead convert env into exported variables */ protected List<String> toCommandSequence(List<String> commands, Map<String,?> env) { List<String> result = new ArrayList<String>((env!=null ? env.size() : 0) + commands.size()); if (env!=null) { for (Entry<String,?> entry : env.entrySet()) { if (entry.getKey() == null || entry.getValue() == null) { LOG.warn("env key-values must not be null; ignoring: key="+entry.getKey()+"; value="+entry.getValue()); continue; } String escapedVal = BashStringEscapes.escapeLiteralForDoubleQuotedBash(entry.getValue().toString()); result.add("export "+entry.getKey()+"=\""+escapedVal+"\""); } } for (CharSequence cmd : commands) { // objects in commands can be groovy GString so can't treat as String here result.add(cmd.toString()); } return result; } @Override public int execScript(Map<String,?> props, List<String> commands) { return execScript(props, commands, Collections.<String,Object>emptyMap()); } @Override public int execCommands(Map<String,?> props, List<String> commands) { return execCommands(props, commands, Collections.<String,Object>emptyMap()); } protected static int asInt(Integer input, int valueIfInputNull) { return input != null ? input : valueIfInputNull; } protected abstract class ToolAbstractExecScript { protected final Map<String, ?> props; protected final String separator; protected final OutputStream out; protected final OutputStream err; protected final String scriptDir; protected final Boolean runAsRoot; protected final Boolean noExtraOutput; protected final Boolean noDeleteAfterExec; protected final String scriptNameWithoutExtension; protected final String scriptPath; protected final Duration execTimeout; public ToolAbstractExecScript(Map<String,?> props) { this.props = props; this.separator = getOptionalVal(props, PROP_SEPARATOR); this.out = getOptionalVal(props, PROP_OUT_STREAM); this.err = getOptionalVal(props, PROP_ERR_STREAM); this.scriptDir = getOptionalVal(props, PROP_SCRIPT_DIR); this.runAsRoot = getOptionalVal(props, PROP_RUN_AS_ROOT); this.noExtraOutput = getOptionalVal(props, PROP_NO_EXTRA_OUTPUT); this.noDeleteAfterExec = getOptionalVal(props, PROP_NO_DELETE_SCRIPT); this.execTimeout = getOptionalVal(props, PROP_EXEC_TIMEOUT); String summary = getOptionalVal(props, PROP_SUMMARY); if (summary!=null) { summary = Strings.makeValidFilename(summary); if (summary.length()>30) summary = summary.substring(0,30); } this.scriptNameWithoutExtension = "brooklyn-"+ Time.makeDateStampString()+"-"+Identifiers.makeRandomId(4)+ (Strings.isBlank(summary) ? "" : "-"+summary); this.scriptPath = Os.mergePathsUnix(scriptDir, scriptNameWithoutExtension+".sh"); } /** builds the command to run the given script; * note that some modes require \$RESULT passed in order to access a variable, whereas most just need $ */ protected List<String> buildRunScriptCommand() { MutableList.Builder<String> cmds = MutableList.<String>builder() .add((runAsRoot ? BashCommands.sudo(scriptPath) : scriptPath) + " < /dev/null") .add("RESULT=$?"); if (noExtraOutput==null || !noExtraOutput) cmds.add("echo Executed "+scriptPath+", result $RESULT"); if (noDeleteAfterExec!=Boolean.TRUE) { // use "-f" because some systems have "rm" aliased to "rm -i" // use "< /dev/null" to guarantee doesn't hang cmds.add("rm -f "+scriptPath+" < /dev/null"); } cmds.add("exit $RESULT"); return cmds.build(); } protected String getSummary() { String summary = getOptionalVal(props, PROP_SUMMARY); return (summary != null) ? summary : scriptPath; } public abstract int run(); } protected abstract class ToolAbstractAsyncExecScript extends ToolAbstractExecScript { protected final String stdoutPath; protected final String stderrPath; protected final String exitStatusPath; protected final String pidPath; public ToolAbstractAsyncExecScript(Map<String,?> props) { super(props); stdoutPath = Os.mergePathsUnix(scriptDir, scriptNameWithoutExtension + ".stdout"); stderrPath = Os.mergePathsUnix(scriptDir, scriptNameWithoutExtension + ".stderr"); exitStatusPath = Os.mergePathsUnix(scriptDir, scriptNameWithoutExtension + ".exitstatus"); pidPath = Os.mergePathsUnix(scriptDir, scriptNameWithoutExtension + ".pid"); } /** * Builds the command to run the given script, asynchronously. * The executed command will return immediately, but the output from the script * will continue to be written * note that some modes require \$RESULT passed in order to access a variable, whereas most just need $ */ @Override protected List<String> buildRunScriptCommand() { String touchCmd = String.format("touch %s %s %s %s", stdoutPath, stderrPath, exitStatusPath, pidPath); String cmd = String.format("nohup sh -c \"( %s > %s 2> %s < /dev/null ) ; echo \\$? > %s \" > /dev/null 2>&1 < /dev/null &", scriptPath, stdoutPath, stderrPath, exitStatusPath); MutableList.Builder<String> cmds = MutableList.<String>builder() .add(runAsRoot ? BashCommands.sudo(touchCmd) : touchCmd) .add(runAsRoot ? BashCommands.sudo(cmd) : cmd) .add("echo $! > "+pidPath) .add("RESULT=$?"); if (noExtraOutput==null || !noExtraOutput) { cmds.add("echo Executing async "+scriptPath); } cmds.add("exit $RESULT"); return cmds.build(); } /** * Builds the command to retrieve the exit status of the command, written to stdout. */ protected List<String> buildRetrieveStatusCommand() { // Retrieve exit status from file (writtent to stdout), if populated; // if not found and pid still running, then return empty string; else exit code 1. List<String> cmdParts = ImmutableList.of( "# Retrieve status", // comment is to aid testing - see SshjToolAsyncStubIntegrationTest "if test -s "+exitStatusPath+"; then", " cat "+exitStatusPath, "elif test -s "+pidPath+"; then", " pid=`cat "+pidPath+"`", " if ! ps -p $pid > /dev/null < /dev/null; then", " # no exit status, and not executing; give a few seconds grace in case just about to write exit status", " sleep 3", " if test -s "+exitStatusPath+"; then", " cat "+exitStatusPath+"", " else", " echo \"No exit status in "+exitStatusPath+", and pid in "+pidPath+" ($pid) not executing\"", " exit 1", " fi", " fi", "else", " echo \"No exit status in "+exitStatusPath+", and "+pidPath+" is empty\"", " exit 1", "fi"+"\n"); String cmd = Joiner.on("\n").join(cmdParts); MutableList.Builder<String> cmds = MutableList.<String>builder() .add((runAsRoot ? BashCommands.sudo(cmd) : cmd)) .add("RESULT=$?"); cmds.add("exit $RESULT"); return cmds.build(); } /** * Builds the command to retrieve the stdout and stderr of the async command. * An offset can be given, to only retrieve data starting at a particular character (indexed from 0). */ protected List<String> buildRetrieveStdoutAndStderrCommand(int stdoutPosition, int stderrPosition) { // Note that `tail -c +1` means start at the *first* character (i.e. start counting from 1, not 0) String catStdoutCmd = "tail -c +"+(stdoutPosition+1)+" "+stdoutPath+" 2> /dev/null"; String catStderrCmd = "tail -c +"+(stderrPosition+1)+" "+stderrPath+" 2>&1 > /dev/null"; MutableList.Builder<String> cmds = MutableList.<String>builder() .add((runAsRoot ? BashCommands.sudo(catStdoutCmd) : catStdoutCmd)) .add((runAsRoot ? BashCommands.sudo(catStderrCmd) : catStderrCmd)) .add("RESULT=$?"); cmds.add("exit $RESULT"); return cmds.build(); } /** * Builds the command to retrieve the stdout and stderr of the async command. * An offset can be given, to only retrieve data starting at a particular character (indexed from 0). */ protected List<String> buildLongPollCommand(int stdoutPosition, int stderrPosition, Duration timeout) { long maxTime = Math.max(1, timeout.toSeconds()); // Note that `tail -c +1` means start at the *first* character (i.e. start counting from 1, not 0) List<String> waitForExitStatusParts = ImmutableList.of( //Should be careful here because any output will be part of the stdout/stderr streams "# Long poll", // comment is to aid testing - see SshjToolAsyncStubIntegrationTest // disown to avoid Terminated message after killing the process // redirect error output to avoid "file truncated" messages "tail -c +"+(stdoutPosition+1)+" -f "+stdoutPath+" 2> /dev/null & export TAIL_STDOUT_PID=$!; disown", "tail -c +"+(stderrPosition+1)+" -f "+stderrPath+" 1>&2 2> /dev/null & export TAIL_STDERR_PID=$!; disown", "EXIT_STATUS_PATH="+exitStatusPath, "PID_PATH="+pidPath, "MAX_TIME="+maxTime, "COUNTER=0", "while [ \"$COUNTER\" -lt $MAX_TIME ]; do", " if test -s $EXIT_STATUS_PATH; then", " EXIT_STATUS=`cat $EXIT_STATUS_PATH`", " kill ${TAIL_STDERR_PID} ${TAIL_STDOUT_PID} 2> /dev/null", " exit $EXIT_STATUS", " elif test -s $PID_PATH; then", " PID=`cat $PID_PATH`", " if ! ps -p $PID > /dev/null 2>&1 < /dev/null; then", " # no exit status, and not executing; give a few seconds grace in case just about to write exit status", " sleep 3", " if test -s $EXIT_STATUS_PATH; then", " EXIT_STATUS=`cat $EXIT_STATUS_PATH`", " kill ${TAIL_STDERR_PID} ${TAIL_STDOUT_PID} 2> /dev/null", " exit $EXIT_STATUS", " else", " echo \"No exit status in $EXIT_STATUS_PATH, and pid in $PID_PATH ($PID) not executing\"", " kill ${TAIL_STDERR_PID} ${TAIL_STDOUT_PID} 2> /dev/null", " exit 126", " fi", " fi", " fi", " # No exit status in $EXIT_STATUS_PATH; keep waiting", " sleep 1", " COUNTER+=1", "done", "kill ${TAIL_STDERR_PID} ${TAIL_STDOUT_PID} 2> /dev/null", "exit 125"+"\n"); String waitForExitStatus = Joiner.on("\n").join(waitForExitStatusParts); return ImmutableList.of(runAsRoot ? BashCommands.sudo(waitForExitStatus) : waitForExitStatus); } protected List<String> deleteTemporaryFilesCommand() { ImmutableList.Builder<String> cmdParts = ImmutableList.builder(); if (!Boolean.TRUE.equals(noDeleteAfterExec)) { // use "-f" because some systems have "rm" aliased to "rm -i" // use "< /dev/null" to guarantee doesn't hang cmdParts.add( "rm -f "+scriptPath+" "+stdoutPath+" "+stderrPath+" "+exitStatusPath+" "+pidPath+" < /dev/null"); } // If the buildLongPollCommand didn't complete properly then it might have left tail command running; // ensure they are killed. cmdParts.add( //ignore error output for the case where there are no running processes and kill is called without arguments "ps aux | grep \"tail -c\" | grep \""+stdoutPath+"\" | grep -v grep | awk '{ printf $2 }' | xargs kill 2> /dev/null", "ps aux | grep \"tail -c\" | grep \""+stderrPath+"\" | grep -v grep | awk '{ printf $2 }' | xargs kill 2> /dev/null"); String cmd = Joiner.on("\n").join(cmdParts.build()); return ImmutableList.of(runAsRoot ? BashCommands.sudo(cmd) : cmd); } @Override public abstract int run(); } }