package jenkins.model; import com.google.common.base.Predicate; import hudson.Extension; import hudson.Util; import hudson.model.Job; import hudson.model.PermalinkProjectAction.Permalink; import hudson.model.Run; import hudson.model.TaskListener; import hudson.model.listeners.RunListener; import hudson.util.AtomicFileWriter; import hudson.util.StreamTaskListener; import java.io.File; import java.io.IOException; import java.io.StringWriter; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nonnull; import javax.annotation.Nullable; import org.apache.commons.io.FileUtils; /** * Convenient base implementation for {@link Permalink}s that satisfy * certain properties. * * <p> * For a permalink to be able to use this, it has to satisfy the following: * * <blockquote> * Given a job J, permalink is a function F that computes a build B. * A peephole permalink is a subset of this function that can be * deduced to the "peep-hole" function G(B)->bool: * * <pre> * F(J) = { newest B | G(B)==true } * </pre> * </blockquote> * * <p> * Intuitively speaking, a peep-hole permalink resolves to the latest build that * satisfies a certain characteristics that can be determined solely by looking * at the build and nothing else (most commonly its build result.) * * <p> * This base class provides a file-based caching mechanism that avoids * walking the long build history. The cache is a symlink to the build directory * where symlinks are supported, and text file that contains the build number otherwise. * * <p> * The implementation transparently tolerates G(B) that goes from true to false over time * (it simply scans the history till find the new matching build.) To tolerate G(B) * that goes from false to true, you need to be able to intercept whenever G(B) changes * from false to true, then call {@link #resolve(Job)} to check the current permalink target * is up to date, then call {@link #updateCache(Job, Run)} if it needs updating. * * @author Kohsuke Kawaguchi * @since 1.507 */ public abstract class PeepholePermalink extends Permalink implements Predicate<Run<?,?>> { /** JENKINS-22822: avoids rereading symlinks */ static final Map<File,String> symlinks = new HashMap<File,String>(); /** * Checks if the given build satisfies the peep-hole criteria. * * This is the "G(B)" as described in the class javadoc. */ public abstract boolean apply(Run<?,?> run); /** * The file in which the permalink target gets recorded. */ protected File getPermalinkFile(Job<?,?> job) { return new File(job.getBuildDir(),getId()); } /** * Resolves the permalink by using the cache if possible. */ @Override public Run<?, ?> resolve(Job<?, ?> job) { File f = getPermalinkFile(job); Run<?,?> b=null; try { String target = readSymlink(f); if (target!=null) { int n = Integer.parseInt(Util.getFileName(target)); if (n==RESOLVES_TO_NONE) return null; b = job.getBuildByNumber(n); if (b!=null && apply(b)) return b; // found it (in the most efficient way possible) // the cache is stale. start the search if (b==null) b=job.getNearestOldBuild(n); } } catch (InterruptedException e) { LOGGER.log(Level.WARNING, "Failed to read permalink cache:" + f, e); // if we fail to read the cache, fall back to the re-computation } catch (NumberFormatException e) { LOGGER.log(Level.WARNING, "Failed to parse the build number in the permalink cache:" + f, e); // if we fail to read the cache, fall back to the re-computation } catch (IOException e) { // this happens when the symlink doesn't exist // (and it cannot be distinguished from the case when the actual I/O error happened } if (b==null) { // no cache b = job.getLastBuild(); } // start from the build 'b' and locate the build that matches the criteria going back in time b = find(b); updateCache(job,b); return b; } /** * Start from the build 'b' and locate the build that matches the criteria going back in time */ private Run<?,?> find(Run<?,?> b) { for ( ; b!=null && !apply(b); b=b.getPreviousBuild()) ; return b; } /** * Remembers the value 'n' in the cache for future {@link #resolve(Job)}. */ protected void updateCache(@Nonnull Job<?,?> job, @Nullable Run<?,?> b) { final int n = b==null ? RESOLVES_TO_NONE : b.getNumber(); File cache = getPermalinkFile(job); cache.getParentFile().mkdirs(); try { String target = String.valueOf(n); if (b != null && !new File(job.getBuildDir(), target).exists()) { // (re)create the build Number->Id symlink Util.createSymlink(job.getBuildDir(),b.getId(),target,TaskListener.NULL); } writeSymlink(cache, target); } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to update "+job+" "+getId()+" permalink for " + b, e); cache.delete(); } catch (InterruptedException e) { LOGGER.log(Level.WARNING, "Failed to update "+job+" "+getId()+" permalink for " + b, e); cache.delete(); } } // File.exists returns false for a link with a missing target, so for Java 6 compatibility we have to use this circuitous method to see if it was created. private static boolean exists(File link) { File[] kids = link.getParentFile().listFiles(); return kids != null && Arrays.asList(kids).contains(link); } static String readSymlink(File cache) throws IOException, InterruptedException { synchronized (symlinks) { String target = symlinks.get(cache); if (target != null) { LOGGER.log(Level.FINE, "readSymlink cached {0} → {1}", new Object[] {cache, target}); return target; } } String target = Util.resolveSymlink(cache); if (target==null && cache.exists()) { // if this file isn't a symlink, it must be a regular file target = FileUtils.readFileToString(cache,"UTF-8").trim(); } LOGGER.log(Level.FINE, "readSymlink {0} → {1}", new Object[] {cache, target}); synchronized (symlinks) { symlinks.put(cache, target); } return target; } static void writeSymlink(File cache, String target) throws IOException, InterruptedException { LOGGER.log(Level.FINE, "writeSymlink {0} → {1}", new Object[] {cache, target}); synchronized (symlinks) { symlinks.put(cache, target); } StringWriter w = new StringWriter(); StreamTaskListener listener = new StreamTaskListener(w); Util.createSymlink(cache.getParentFile(),target,cache.getName(),listener); // Avoid calling resolveSymlink on a nonexistent file as it will probably throw an IOException: if (!exists(cache) || Util.resolveSymlink(cache)==null) { // symlink not supported. use a regular file AtomicFileWriter cw = new AtomicFileWriter(cache); try { cw.write(target); cw.commit(); } finally { cw.abort(); } } } @Extension public static class RunListenerImpl extends RunListener<Run<?,?>> { /** * If any of the peephole permalink points to the build to be deleted, update it to point to the new location. */ @Override public void onDeleted(Run run) { Job<?, ?> j = run.getParent(); for (PeepholePermalink pp : Util.filter(j.getPermalinks(), PeepholePermalink.class)) { if (pp.resolve(j)==run) { Run<?,?> r = pp.find(run.getPreviousBuild()); if (LOGGER.isLoggable(Level.FINE)) LOGGER.fine("Updating "+pp.getPermalinkFile(j).getName()+" permalink from deleted "+run.getNumber()+" to "+(r == null ? -1 : r.getNumber())); pp.updateCache(j,r); } } } /** * See if the new build matches any of the peephole permalink. */ @Override public void onCompleted(Run<?,?> run, @Nonnull TaskListener listener) { Job<?, ?> j = run.getParent(); for (PeepholePermalink pp : Util.filter(j.getPermalinks(), PeepholePermalink.class)) { if (pp.apply(run)) { Run<?, ?> cur = pp.resolve(j); if (cur==null || cur.getNumber()<run.getNumber()) { if (LOGGER.isLoggable(Level.FINE)) LOGGER.fine("Updating "+pp.getPermalinkFile(j).getName()+" permalink to completed "+run.getNumber()); pp.updateCache(j,run); } } } } } private static final int RESOLVES_TO_NONE = -1; private static final Logger LOGGER = Logger.getLogger(PeepholePermalink.class.getName()); }