/* * The MIT License * * Copyright 2014 CloudBees, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package org.jenkinsci.plugins.durabletask; import hudson.EnvVars; import hudson.Extension; import hudson.FilePath; import hudson.Launcher; import hudson.LauncherDecorator; import hudson.Platform; import hudson.Util; import hudson.model.TaskListener; import hudson.tasks.Shell; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.WeakHashMap; import javax.annotation.Nonnull; import jenkins.model.Jenkins; import jenkins.security.MasterToSlaveCallable; import org.kohsuke.stapler.DataBoundConstructor; /** * Runs a Bourne shell script on a Unix node using {@code nohup}. */ public final class BourneShellScript extends FileMonitoringTask { private static enum OsType {DARWIN, UNIX, WINDOWS} /** Number of times we will show launch diagnostics in a newly encountered workspace before going mute to save resources. */ private static /* not final */ int NOVEL_WORKSPACE_DIAGNOSTICS_COUNT = Integer.getInteger(BourneShellScript.class.getName() + ".NOVEL_WORKSPACE_DIAGNOSTICS_COUNT", 10); /** Number of seconds we will wait for a controller script to be launched before assuming the launch failed. */ private static /* not final */ int LAUNCH_FAILURE_TIMEOUT = Integer.getInteger(BourneShellScript.class.getName() + ".LAUNCH_FAILURE_TIMEOUT", 15); private final @Nonnull String script; private boolean capturingOutput; @DataBoundConstructor public BourneShellScript(String script) { this.script = Util.fixNull(script); } public String getScript() { return script; } @Override public void captureOutput() { capturingOutput = true; } /** * Set of workspaces which we have already run a process in. * Copying output from the controller process consumes a Java thread, so we want to avoid it generally. * But we do it the first few times we run a process in a new workspace, to assist in diagnosis. * (For example, if we are unable to write to it due to permissions, we want to see that error message.) * Ideally we would display output the first time a given {@link Launcher} was used in that workspace, * but this seems impractical since {@link LauncherDecorator#decorate} may be called anew for each process, * and forcing the resulting {@link Launcher}s to implement {@link Launcher#equals} seems onerous. */ private static final Map<FilePath,Integer> encounteredPaths = new WeakHashMap<FilePath,Integer>(); @Override protected FileMonitoringController launchWithCookie(FilePath ws, Launcher launcher, TaskListener listener, EnvVars envVars, String cookieVariable, String cookieValue) throws IOException, InterruptedException { if (script.isEmpty()) { listener.getLogger().println("Warning: was asked to run an empty script"); } ShellController c = new ShellController(ws); FilePath shf = c.getScriptFile(ws); String s = script, scriptPath; final Jenkins jenkins = Jenkins.getInstance(); if (!s.startsWith("#!") && jenkins != null) { String defaultShell = jenkins.getInjector().getInstance(Shell.DescriptorImpl.class).getShellOrDefault(ws.getChannel()); s = "#!"+defaultShell+" -xe\n" + s; } shf.write(s, "UTF-8"); shf.chmod(0755); scriptPath = shf.getRemote(); List<String> args = new ArrayList<String>(); OsType os = ws.act(new getOsType()); if (os != OsType.DARWIN) { // JENKINS-25848 args.add("nohup"); } if (os == OsType.WINDOWS) { // JENKINS-40255 scriptPath= scriptPath.replace("\\", "/"); // cygwin sh understands mixed path (ie : "c:/jenkins/workspace/script.sh" ) } envVars.put(cookieVariable, "please-do-not-kill-me"); // The temporary variable is to ensure JENKINS_SERVER_COOKIE=durable-… does not appear even in argv[], lest it be confused with the environment. String cmd; if (capturingOutput) { cmd = String.format("echo $$ > '%s'; jsc=%s; %s=$jsc '%s' > '%s' 2> '%s'; echo $? > '%s'", c.pidFile(ws), cookieValue, cookieVariable, scriptPath, c.getOutputFile(ws), c.getLogFile(ws), c.getResultFile(ws)); } else { cmd = String.format("echo $$ > '%s'; jsc=%s; %s=$jsc '%s' > '%s' 2>&1; echo $? > '%s'", c.pidFile(ws), cookieValue, cookieVariable, scriptPath, c.getLogFile(ws), c.getResultFile(ws)); } cmd = cmd.replace("$", "$$"); // escape against EnvVars jobEnv in LocalLauncher.launch args.addAll(Arrays.asList("sh", "-c", cmd)); Launcher.ProcStarter ps = launcher.launch().cmds(args).envs(escape(envVars)).pwd(ws).quiet(true); listener.getLogger().println("[" + ws.getRemote().replaceFirst("^.+/", "") + "] Running shell script"); // -x will give details boolean novel; synchronized (encounteredPaths) { Integer cnt = encounteredPaths.get(ws); if (cnt == null) { cnt = 0; } novel = cnt < NOVEL_WORKSPACE_DIAGNOSTICS_COUNT; encounteredPaths.put(ws, cnt + 1); } if (novel) { // First time in this combination. Display any output from the wrapper script for diagnosis. ps.stdout(listener); } else { // Second or subsequent time. Suppress output to save a thread. ps.readStdout().readStderr(); // TODO RemoteLauncher.launch fails to check ps.stdout == NULL_OUTPUT_STREAM, so it creates a useless thread even if you never called stdout(…) } ps.start(); return c; } /*package*/ static final class ShellController extends FileMonitoringController { private int pid; private final long startTime = System.currentTimeMillis(); private ShellController(FilePath ws) throws IOException, InterruptedException { super(ws); } public FilePath getScriptFile(FilePath ws) throws IOException, InterruptedException { return controlDir(ws).child("script.sh"); } FilePath pidFile(FilePath ws) throws IOException, InterruptedException { return controlDir(ws).child("pid"); } private synchronized int pid(FilePath ws) throws IOException, InterruptedException { if (pid == 0) { FilePath pidFile = pidFile(ws); if (pidFile.exists()) { try { pid = Integer.parseInt(pidFile.readToString().trim()); } catch (NumberFormatException x) { throw new IOException("corrupted content in " + pidFile + ": " + x, x); } } } return pid; } @Override public Integer exitStatus(FilePath workspace, Launcher launcher) throws IOException, InterruptedException { Integer status = super.exitStatus(workspace, launcher); if (status != null) { return status; } int _pid = pid(workspace); if (_pid > 0 && !ProcessLiveness.isAlive(workspace.getChannel(), _pid, launcher)) { // it looks like the process has disappeared. one last check to make sure it's not a result of a race condition, // then if we still don't have the exit code, use fake exit code to distinguish from 0 (success) and 1+ (observed failure) // TODO would be better to have exitStatus accept a TaskListener so we could print an informative message status = super.exitStatus(workspace, launcher); if (status == null) { status = -1; } return status; } else if (_pid == 0 && /* compatibility */ startTime > 0 && System.currentTimeMillis() - startTime > 1000 * LAUNCH_FAILURE_TIMEOUT) { return -2; // apparently never started } return null; } @Override public String getDiagnostics(FilePath workspace, Launcher launcher) throws IOException, InterruptedException { return super.getDiagnostics(workspace, launcher) + " (pid: " + pid + ")"; } private static final long serialVersionUID = 1L; } @Extension public static final class DescriptorImpl extends DurableTaskDescriptor { @Override public String getDisplayName() { return Messages.BourneShellScript_bourne_shell(); } } private static final class getOsType extends MasterToSlaveCallable<OsType,RuntimeException> { @Override public OsType call() throws RuntimeException { if (Platform.isDarwin()) { return OsType.DARWIN; } else if (Platform.current() == Platform.WINDOWS) { return OsType.WINDOWS; } else { return OsType.UNIX; // Default Value } } } }