/*******************************************************************************
*
* Copyright (c) 2004-2009 Oracle Corporation.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
*
* Kohsuke Kawaguchi
*
*
*******************************************************************************/
package hudson.model;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.converters.Converter;
import com.thoughtworks.xstream.converters.MarshallingContext;
import com.thoughtworks.xstream.converters.UnmarshallingContext;
import com.thoughtworks.xstream.converters.collections.CollectionConverter;
import com.thoughtworks.xstream.io.HierarchicalStreamReader;
import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
import hudson.Util;
import hudson.XmlFile;
import hudson.BulkChange;
import hudson.model.listeners.SaveableListener;
import hudson.util.HexBinaryConverter;
import hudson.util.Iterators;
import hudson.util.XStream2;
import org.kohsuke.stapler.export.Exported;
import org.kohsuke.stapler.export.ExportedBean;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.List;
import java.util.Map.Entry;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* A file being tracked by Hudson.
*
* <p> Lifecycle is managed by {@link FingerprintMap}.
*
* @author Kohsuke Kawaguchi
* @see FingerprintMap
*/
@ExportedBean
public class Fingerprint implements ModelObject, Saveable {
/**
* Pointer to a {@link Build}.
*/
@ExportedBean(defaultVisibility = 2)
public static class BuildPtr {
final String name;
final int number;
public BuildPtr(String name, int number) {
this.name = name;
this.number = number;
}
public BuildPtr(Run run) {
this(run.getParent().getFullName(), run.getNumber());
}
/**
* Gets {@link Job#getFullName() the full name of the job}. <p> Such job
* could be since then removed, so there might not be a corresponding
* {@link Job}.
*/
@Exported
public String getName() {
return name;
}
/**
* Gets the {@link Job} that this pointer points to, or null if such a
* job no longer exists.
*/
public AbstractProject getJob() {
return Hudson.getInstance().getItemByFullName(name, AbstractProject.class);
}
/**
* Gets the project build number. <p> Such {@link Run} could be since
* then discarded.
*/
@Exported
public int getNumber() {
return number;
}
/**
* Gets the {@link Job} that this pointer points to, or null if such a
* job no longer exists.
*/
public Run getRun() {
Job j = getJob();
if (j == null) {
return null;
}
return j.getBuildByNumber(number);
}
private boolean isAlive() {
return getRun() != null;
}
/**
* Returns true if {@link BuildPtr} points to the given run.
*/
public boolean is(Run r) {
return r.getNumber() == number && r.getParent().getFullName().equals(name);
}
/**
* Returns true if {@link BuildPtr} points to the given job.
*/
public boolean is(Job job) {
return job.getFullName().equals(name);
}
/**
* Returns true if {@link BuildPtr} points to the given job or one of
* its subordinates.
*
* <p> This is useful to check if an artifact in MavenModule belongs to
* MavenModuleSet.
*/
public boolean belongsTo(Job job) {
Item p = Hudson.getInstance().getItemByFullName(name);
while (p != null) {
if (p == job) {
return true;
}
// go up the chain while we
ItemGroup<? extends Item> parent = p.getParent();
if (!(parent instanceof Item)) {
return false;
}
p = (Item) parent;
}
return false;
}
}
/**
* Range of build numbers [start,end). Immutable.
*/
@ExportedBean(defaultVisibility = 4)
public static final class Range {
final int start;
final int end;
public Range(int start, int end) {
assert start < end;
this.start = start;
this.end = end;
}
@Exported
public int getStart() {
return start;
}
@Exported
public int getEnd() {
return end;
}
public boolean isSmallerThan(int i) {
return end <= i;
}
public boolean isBiggerThan(int i) {
return i < start;
}
public boolean includes(int i) {
return start <= i && i < end;
}
public Range expandRight() {
return new Range(start, end + 1);
}
public Range expandLeft() {
return new Range(start - 1, end);
}
public boolean isAdjacentTo(Range that) {
return this.end == that.start;
}
@Override
public String toString() {
return "[" + start + "," + end + ")";
}
/**
* Returns true if two {@link Range}s can't be combined into a single
* range.
*/
public boolean isIndependent(Range that) {
return this.end < that.start || that.end < this.start;
}
/**
* Returns true if this range only represents a single number.
*/
public boolean isSingle() {
return end - 1 == start;
}
/**
* Returns the {@link Range} that combines two ranges.
*/
public Range combine(Range that) {
assert !isIndependent(that);
return new Range(
Math.min(this.start, that.start),
Math.max(this.end, that.end));
}
}
/**
* Set of {@link Range}s.
*/
@ExportedBean(defaultVisibility = 3)
public static final class RangeSet {
// sorted
private final List<Range> ranges;
public RangeSet() {
this(new ArrayList<Range>());
}
private RangeSet(List<Range> data) {
this.ranges = data;
}
/**
* List all numbers in this range set, in the ascending order.
*/
public Iterable<Integer> listNumbers() {
final List<Range> ranges = getRanges();
return new Iterable<Integer>() {
public Iterator<Integer> iterator() {
return new Iterators.FlattenIterator<Integer, Range>(ranges) {
protected Iterator<Integer> expand(Range range) {
return Iterators.sequence(range.start, range.end).iterator();
}
};
}
};
}
// /**
// * List up builds.
// */
// public <J extends Job<J,R>,R extends Run<J,R>> Iterable<R> listBuilds(final J job) {
// return new Iterable<R>() {
// public Iterator<R> iterator() {
// return new Iterators.FilterIterator<R>(new AdaptedIterator<Integer,R>(listNumbers().iterator()) {
// protected R adapt(Integer n) {
// return job.getBuildByNumber(n);
// }
// }) {
// protected boolean filter(R r) {
// return r!=null;
// }
// };
// }
// };
// }
/**
* List all numbers in this range set in the descending order.
*/
public Iterable<Integer> listNumbersReverse() {
final List<Range> ranges = getRanges();
return new Iterable<Integer>() {
public Iterator<Integer> iterator() {
return new Iterators.FlattenIterator<Integer, Range>(Iterators.reverse(ranges)) {
protected Iterator<Integer> expand(Range range) {
return Iterators.reverseSequence(range.start, range.end).iterator();
}
};
}
};
}
/**
* Gets all the ranges.
*/
@Exported
public synchronized List<Range> getRanges() {
return new ArrayList<Range>(ranges);
}
/**
* Expands the range set to include the given value. If the set already
* includes this number, this will be a no-op.
*/
public synchronized void add(int n) {
for (int i = 0; i < ranges.size(); i++) {
Range r = ranges.get(i);
if (r.includes(n)) {
return; // already included
}
if (r.end == n) {
ranges.set(i, r.expandRight());
checkCollapse(i);
return;
}
if (r.start == n + 1) {
ranges.set(i, r.expandLeft());
checkCollapse(i - 1);
return;
}
if (r.isBiggerThan(n)) {
// needs to insert a single-value Range
ranges.add(i, new Range(n, n + 1));
return;
}
}
ranges.add(new Range(n, n + 1));
}
private void checkCollapse(int i) {
if (i < 0 || i == ranges.size() - 1) {
return;
}
Range lhs = ranges.get(i);
Range rhs = ranges.get(i + 1);
if (lhs.isAdjacentTo(rhs)) {
// collapsed
Range r = new Range(lhs.start, rhs.end);
ranges.set(i, r);
ranges.remove(i + 1);
}
}
public synchronized boolean includes(int i) {
for (Range r : ranges) {
if (r.includes(i)) {
return true;
}
}
return false;
}
public synchronized void add(RangeSet that) {
int lhs = 0, rhs = 0;
while (lhs < this.ranges.size() && rhs < that.ranges.size()) {
Range lr = this.ranges.get(lhs);
Range rr = that.ranges.get(rhs);
// no overlap
if (lr.end < rr.start) {
lhs++;
continue;
}
if (rr.end < lr.start) {
ranges.add(lhs, rr);
lhs++;
rhs++;
continue;
}
// overlap. merge two
Range m = lr.combine(rr);
rhs++;
// since ranges[lhs] is expanded, it might overlap with others in this.ranges
while (lhs + 1 < this.ranges.size() && !m.isIndependent(this.ranges.get(lhs + 1))) {
m = m.combine(this.ranges.get(lhs + 1));
this.ranges.remove(lhs + 1);
}
this.ranges.set(lhs, m);
}
// if anything is left in that.ranges, add them all
this.ranges.addAll(that.ranges.subList(rhs, that.ranges.size()));
}
@Override
public synchronized String toString() {
StringBuilder buf = new StringBuilder();
for (Range r : ranges) {
if (buf.length() > 0) {
buf.append(',');
}
buf.append(r);
}
return buf.toString();
}
public synchronized boolean isEmpty() {
return ranges.isEmpty();
}
/**
* Returns the smallest value in this range. <p> If this range is empty,
* this method throws an exception.
*/
public synchronized int min() {
return ranges.get(0).start;
}
/**
* Returns the largest value in this range. <p> If this range is empty,
* this method throws an exception.
*/
public synchronized int max() {
return ranges.get(ranges.size() - 1).end;
}
/**
* Returns true if all the integers logically in this {@link RangeSet}
* is smaller than the given integer. For example, {[1,3)} is smaller
* than 3, but {[1,3),[100,105)} is not smaller than anything less than
* 105.
*
* Note that {} is smaller than any n.
*/
public synchronized boolean isSmallerThan(int n) {
if (ranges.isEmpty()) {
return true;
}
return ranges.get(ranges.size() - 1).isSmallerThan(n);
}
/**
* Parses a {@link RangeSet} from a string like "1-3,5,7-9"
*/
public static RangeSet fromString(String list, boolean skipError) {
RangeSet rs = new RangeSet();
for (String s : Util.tokenize(list, ",")) {
s = s.trim();
// s is either single number or range "x-y".
// note that the end range is inclusive in this notation, but not in the Range class
try {
if (s.contains("-")) {
String[] tokens = Util.tokenize(s, "-");
rs.ranges.add(new Range(Integer.parseInt(tokens[0]), Integer.parseInt(tokens[1]) + 1));
} else {
int n = Integer.parseInt(s);
rs.ranges.add(new Range(n, n + 1));
}
} catch (NumberFormatException e) {
if (!skipError) {
throw new IllegalArgumentException("Unable to parse " + list);
}
// ignore malformed text
}
}
return rs;
}
static final class ConverterImpl implements Converter {
private final Converter collectionConv; // used to convert ArrayList in it
public ConverterImpl(Converter collectionConv) {
this.collectionConv = collectionConv;
}
public boolean canConvert(Class type) {
return type == RangeSet.class;
}
public void marshal(Object source, HierarchicalStreamWriter writer, MarshallingContext context) {
RangeSet src = (RangeSet) source;
StringBuilder buf = new StringBuilder(src.ranges.size() * 10);
for (Range r : src.ranges) {
if (buf.length() > 0) {
buf.append(',');
}
if (r.isSingle()) {
buf.append(r.start);
} else {
buf.append(r.start).append('-').append(r.end - 1);
}
}
writer.setValue(buf.toString());
}
public Object unmarshal(HierarchicalStreamReader reader, final UnmarshallingContext context) {
if (reader.hasMoreChildren()) {
/* old format where <range> elements are nested like
<range>
<start>1337</start>
<end>1479</end>
</range>
*/
return new RangeSet((List<Range>) (collectionConv.unmarshal(reader, context)));
} else {
return RangeSet.fromString(reader.getValue(), true);
}
}
}
}
private final Date timestamp;
/**
* Null if this fingerprint is for a file that's apparently produced
* outside.
*/
private final BuildPtr original;
private final byte[] md5sum;
private final String fileName;
/**
* Range of builds that use this file keyed by a job full name.
*/
private final Hashtable<String, RangeSet> usages = new Hashtable<String, RangeSet>();
public Fingerprint(Run build, String fileName, byte[] md5sum) throws IOException {
this.original = build == null ? null : new BuildPtr(build);
this.md5sum = md5sum;
this.fileName = fileName;
this.timestamp = new Date();
save();
}
/**
* The first build in which this file showed up, if the file looked like
* it's created there. <p> This is considered as the "source" of this file,
* or the owner, in the sense that this project "owns" this file.
*
* @return null if the file is apparently created outside Hudson.
*/
@Exported
public BuildPtr getOriginal() {
return original;
}
public String getDisplayName() {
return fileName;
}
/**
* The file name (like "foo.jar" without path).
*/
@Exported
public String getFileName() {
return fileName;
}
/**
* Gets the MD5 hash string.
*/
@Exported(name = "hash")
public String getHashString() {
return Util.toHexString(md5sum);
}
/**
* Gets the timestamp when this record is created.
*/
@Exported
public Date getTimestamp() {
return timestamp;
}
/**
* Gets the string that says how long since this build has scheduled.
*
* @return string like "3 minutes" "1 day" etc.
*/
public String getTimestampString() {
long duration = System.currentTimeMillis() - timestamp.getTime();
return Util.getPastTimeString(duration);
}
/**
* Gets the build range set for the given job name.
*
* <p> These builds of this job has used this file.
*/
public RangeSet getRangeSet(String jobFullName) {
RangeSet r = usages.get(jobFullName);
if (r == null) {
r = new RangeSet();
}
return r;
}
public RangeSet getRangeSet(Job job) {
return getRangeSet(job.getFullName());
}
/**
* Gets the sorted list of job names where this jar is used.
*/
public List<String> getJobs() {
List<String> r = new ArrayList<String>();
r.addAll(usages.keySet());
Collections.sort(r);
return r;
}
public Hashtable<String, RangeSet> getUsages() {
return usages;
}
@ExportedBean(defaultVisibility = 2)
public static final class RangeItem {
//TODO: review and check whether we can do it private
@Exported
public final String name;
//TODO: review and check whether we can do it private
@Exported
public final RangeSet ranges;
public String getName() {
return name;
}
public RangeSet getRanges() {
return ranges;
}
public RangeItem(String name, RangeSet ranges) {
this.name = name;
this.ranges = ranges;
}
}
// this is for remote API
@Exported(name = "usage")
public List<RangeItem> _getUsages() {
List<RangeItem> r = new ArrayList<RangeItem>();
for (Entry<String, RangeSet> e : usages.entrySet()) {
r.add(new RangeItem(e.getKey(), e.getValue()));
}
return r;
}
public synchronized void add(AbstractBuild b) throws IOException {
add(b.getParent().getFullName(), b.getNumber());
}
/**
* Records that a build of a job has used this file.
*/
public synchronized void add(String jobFullName, int n) throws IOException {
synchronized (usages) {
RangeSet r = usages.get(jobFullName);
if (r == null) {
r = new RangeSet();
usages.put(jobFullName, r);
}
r.add(n);
}
save();
}
/**
* Returns true if any of the builds recorded in this fingerprint is still
* retained.
*
* <p> This is used to find out old fingerprint records that can be removed
* without losing too much information.
*/
public synchronized boolean isAlive() {
if (original != null && original.isAlive()) {
return true;
}
for (Entry<String, RangeSet> e : usages.entrySet()) {
Job j = Hudson.getInstance().getItemByFullName(e.getKey(), Job.class);
if (j == null) {
continue;
}
int oldest = j.getFirstBuild().getNumber();
if (!e.getValue().isSmallerThan(oldest)) {
return true;
}
}
return false;
}
/**
* Save the settings to a file.
*/
public synchronized void save() throws IOException {
if (BulkChange.contains(this)) {
return;
}
long start = 0;
if (logger.isLoggable(Level.FINE)) {
start = System.currentTimeMillis();
}
File file = getFingerprintFile(md5sum);
getConfigFile(file).write(this);
SaveableListener.fireOnChange(this, getConfigFile(file));
if (logger.isLoggable(Level.FINE)) {
logger.fine("Saving fingerprint " + file + " took " + (System.currentTimeMillis() - start) + "ms");
}
}
public Api getApi() {
return new Api(this);
}
/**
* The file we save our configuration.
*/
private static XmlFile getConfigFile(File file) {
return new XmlFile(XSTREAM, file);
}
/**
* Determines the file name from md5sum.
*/
private static File getFingerprintFile(byte[] md5sum) {
assert md5sum.length == 16;
return new File(Hudson.getInstance().getRootDir(),
"fingerprints/" + Util.toHexString(md5sum, 0, 1) + '/' + Util.toHexString(md5sum, 1, 1) + '/' + Util.toHexString(md5sum, 2, md5sum.length - 2) + ".xml");
}
/**
* Loads a {@link Fingerprint} from a file in the image.
*/
/*package*/ static Fingerprint load(byte[] md5sum) throws IOException {
return load(getFingerprintFile(md5sum));
}
/*package*/ static Fingerprint load(File file) throws IOException {
XmlFile configFile = getConfigFile(file);
if (!configFile.exists()) {
return null;
}
long start = 0;
if (logger.isLoggable(Level.FINE)) {
start = System.currentTimeMillis();
}
try {
Fingerprint f = (Fingerprint) configFile.read();
if (logger.isLoggable(Level.FINE)) {
logger.fine("Loading fingerprint " + file + " took " + (System.currentTimeMillis() - start) + "ms");
}
return f;
} catch (IOException e) {
if (file.exists() && file.length() == 0) {
// Despite the use of AtomicFile, there are reports indicating that people often see
// empty XML file, presumably either due to file system corruption (perhaps by sudden
// power loss, etc.) or abnormal program termination.
// generally we don't want to wipe out user data just because we can't load it,
// but if the file size is 0, which is what's reported in HUDSON-2012, then it seems
// like recovering it silently by deleting the file is not a bad idea.
logger.log(Level.WARNING, "Size zero fingerprint. Disk corruption? " + configFile, e);
file.delete();
return null;
}
logger.log(Level.WARNING, "Failed to load " + configFile, e);
throw e;
}
}
private static final XStream XSTREAM = new XStream2();
static {
XSTREAM.alias("fingerprint", Fingerprint.class);
XSTREAM.alias("range", Range.class);
XSTREAM.alias("ranges", RangeSet.class);
XSTREAM.registerConverter(new HexBinaryConverter(), 10);
XSTREAM.registerConverter(new RangeSet.ConverterImpl(
new CollectionConverter(XSTREAM.getMapper()) {
@Override
protected Object createCollection(Class type) {
return new ArrayList();
}
}), 10);
}
private static final Logger logger = Logger.getLogger(Fingerprint.class.getName());
}