/*
* 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.FilePath;
import hudson.Launcher;
import hudson.Util;
import hudson.model.TaskListener;
import hudson.remoting.Channel;
import hudson.remoting.RemoteOutputStream;
import hudson.remoting.VirtualChannel;
import hudson.slaves.WorkspaceList;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.util.Collections;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.MasterToSlaveFileCallable;
import org.apache.commons.io.IOUtils;
/**
* A task which forks some external command and then waits for log and status files to be updated/created.
*/
public abstract class FileMonitoringTask extends DurableTask {
private static final Logger LOGGER = Logger.getLogger(FileMonitoringTask.class.getName());
private static final String COOKIE = "JENKINS_SERVER_COOKIE";
private static String cookieFor(FilePath workspace) {
return "durable-" + Util.getDigestOf(workspace.getRemote());
}
@Override public final Controller launch(EnvVars env, FilePath workspace, Launcher launcher, TaskListener listener) throws IOException, InterruptedException {
return launchWithCookie(workspace, launcher, listener, env, COOKIE, cookieFor(workspace));
}
protected FileMonitoringController launchWithCookie(FilePath workspace, Launcher launcher, TaskListener listener, EnvVars envVars, String cookieVariable, String cookieValue) throws IOException, InterruptedException {
envVars.put(cookieVariable, cookieValue); // ensure getCharacteristicEnvVars does not match, so Launcher.killAll will leave it alone
return doLaunch(workspace, launcher, listener, envVars);
}
/**
* Should start a process which sends output to {@linkplain FileMonitoringController#getLogFile(FilePath) log file}
* in the workspace and finally writes its exit code to {@linkplain FileMonitoringController#getResultFile(FilePath) result file}.
* @param workspace the workspace to use
* @param launcher a way to launch processes
* @param listener build console log
* @param envVars recommended environment for the subprocess
* @return a specialized controller
*/
protected FileMonitoringController doLaunch(FilePath workspace, Launcher launcher, TaskListener listener, EnvVars envVars) throws IOException, InterruptedException {
throw new AbstractMethodError("override either doLaunch or launchWithCookie");
}
/**
* JENKINS-40734: blocks the substitutions of {@link EnvVars#overrideExpandingAll} done by {@link Launcher}.
*/
protected static Map<String, String> escape(EnvVars envVars) {
Map<String, String> m = new TreeMap<String, String>();
for (Map.Entry<String, String> entry : envVars.entrySet()) {
m.put(entry.getKey(), entry.getValue().replace("$", "$$"));
}
return m;
}
protected static class FileMonitoringController extends Controller {
/** Absolute path of {@link #controlDir(FilePath)}. */
private String controlDir;
/**
* @deprecated used only in pre-1.8
*/
private String id;
/**
* Byte offset in the file that has been reported thus far.
*/
private long lastLocation;
protected FileMonitoringController(FilePath ws) throws IOException, InterruptedException {
// can't keep ws reference because Controller is expected to be serializable
ws.mkdirs();
FilePath cd = tempDir(ws).child("durable-" + Util.getDigestOf(UUID.randomUUID().toString()).substring(0,8));
cd.mkdirs();
controlDir = cd.getRemote();
}
@Override public final boolean writeLog(FilePath workspace, OutputStream sink) throws IOException, InterruptedException {
FilePath log = getLogFile(workspace);
Long newLocation = log.act(new WriteLog(lastLocation, new RemoteOutputStream(sink)));
if (newLocation != null) {
LOGGER.log(Level.FINE, "copied {0} bytes from {1}", new Object[] {newLocation - lastLocation, log});
lastLocation = newLocation;
return true;
} else {
return false;
}
}
private static class WriteLog extends MasterToSlaveFileCallable<Long> {
private final long lastLocation;
private final OutputStream sink;
WriteLog(long lastLocation, OutputStream sink) {
this.lastLocation = lastLocation;
this.sink = sink;
}
@Override public Long invoke(File f, VirtualChannel channel) throws IOException, InterruptedException {
long len = f.length();
if (len > lastLocation) {
RandomAccessFile raf = new RandomAccessFile(f, "r");
try {
raf.seek(lastLocation);
long toRead = len - lastLocation;
if (toRead > Integer.MAX_VALUE) { // >2Gb of output at once is unlikely
throw new IOException("large reads not yet implemented");
}
// TODO is this efficient for large amounts of output? Would it be better to stream data, or return a byte[] from the callable?
byte[] buf = new byte[(int) toRead];
raf.readFully(buf);
sink.write(buf);
} finally {
raf.close();
}
return len;
} else {
return null;
}
}
}
// TODO would be more efficient to allow API to consolidate writeLog with exitStatus (save an RPC call)
@Override public Integer exitStatus(FilePath workspace, Launcher launcher) throws IOException, InterruptedException {
FilePath status = getResultFile(workspace);
if (status.exists()) {
try {
return Integer.parseInt(status.readToString().trim());
} catch (NumberFormatException x) {
throw new IOException("corrupted content in " + status + ": " + x, x);
}
} else {
return null;
}
}
@Override public byte[] getOutput(FilePath workspace, Launcher launcher) throws IOException, InterruptedException {
// TODO could perhaps be more efficient for large files to send a MasterToSlaveFileCallable<byte[]>
try (InputStream is = getOutputFile(workspace).read()) {
return IOUtils.toByteArray(is);
}
}
@Override public final void stop(FilePath workspace, Launcher launcher) throws IOException, InterruptedException {
launcher.kill(Collections.singletonMap(COOKIE, cookieFor(workspace)));
}
@Override public void cleanup(FilePath workspace) throws IOException, InterruptedException {
controlDir(workspace).deleteRecursive();
}
/**
* Directory in which this controller can place files.
* Unique among all the controllers sharing the same workspace.
*/
public FilePath controlDir(FilePath ws) throws IOException, InterruptedException {
if (controlDir != null) { // normal case
return ws.child(controlDir); // despite the name, this is an absolute path
}
assert id != null;
FilePath cd = ws.child("." + id); // compatibility with 1.6
if (!cd.isDirectory()) {
cd = ws.child(".jenkins-" + id); // compatibility with 1.7
}
controlDir = cd.getRemote();
id = null;
LOGGER.info("using migrated control directory " + controlDir + " for remainder of this task");
return cd;
}
// TODO 1.652 use WorkspaceList.tempDir
private static FilePath tempDir(FilePath ws) {
return ws.sibling(ws.getName() + System.getProperty(WorkspaceList.class.getName(), "@") + "tmp");
}
/**
* File in which the exit code of the process should be reported.
*/
public FilePath getResultFile(FilePath workspace) throws IOException, InterruptedException {
return controlDir(workspace).child("jenkins-result.txt");
}
/**
* File in which the stdout/stderr (or, if {@link #captureOutput} is called, just stderr) is written.
*/
public FilePath getLogFile(FilePath workspace) throws IOException, InterruptedException {
return controlDir(workspace).child("jenkins-log.txt");
}
/**
* File in which the stdout is written, if {@link #captureOutput} is called.
*/
public FilePath getOutputFile(FilePath workspace) throws IOException, InterruptedException {
return controlDir(workspace).child("output.txt");
}
@Override public String getDiagnostics(FilePath workspace, Launcher launcher) throws IOException, InterruptedException {
FilePath cd = controlDir(workspace);
VirtualChannel channel = cd.getChannel();
String node = (channel instanceof Channel) ? ((Channel) channel).getName() : null;
String location = node != null ? cd.getRemote() + " on " + node : cd.getRemote();
Integer code = exitStatus(workspace, launcher);
if (code != null) {
return "completed process (code " + code + ") in " + location;
} else {
return "awaiting process completion in " + location;
}
}
private static final long serialVersionUID = 1L;
}
}