/*
* The MIT License
*
* Copyright (c) 2004-2010, Sun Microsystems, Inc., Alan Harder
*
* 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 hudson.diagnosis;
import com.google.common.base.Predicate;
import com.thoughtworks.xstream.converters.UnmarshallingContext;
import hudson.Extension;
import hudson.XmlFile;
import hudson.model.AdministrativeMonitor;
import hudson.model.Item;
import hudson.model.Job;
import hudson.model.ManagementLink;
import hudson.model.Run;
import hudson.model.Saveable;
import hudson.model.listeners.ItemListener;
import hudson.model.listeners.RunListener;
import hudson.model.listeners.SaveableListener;
import hudson.security.ACL;
import hudson.util.RobustReflectionConverter;
import hudson.util.VersionNumber;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.CheckForNull;
import jenkins.model.Jenkins;
import org.acegisecurity.context.SecurityContext;
import org.acegisecurity.context.SecurityContextHolder;
import org.jenkinsci.Symbol;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.HttpRedirect;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.kohsuke.stapler.interceptor.RequirePOST;
/**
* Tracks whether any data structure changes were corrected when loading XML,
* that could be resaved to migrate that data to the new format.
*
* @author Alan.Harder@Sun.Com
*/
@Extension @Symbol("oldData")
public class OldDataMonitor extends AdministrativeMonitor {
private static final Logger LOGGER = Logger.getLogger(OldDataMonitor.class.getName());
private ConcurrentMap<SaveableReference,VersionRange> data = new ConcurrentHashMap<SaveableReference,VersionRange>();
static OldDataMonitor get(Jenkins j) {
return (OldDataMonitor) j.getAdministrativeMonitor("OldData");
}
public OldDataMonitor() {
super("OldData");
}
@Override
public String getDisplayName() {
return Messages.OldDataMonitor_DisplayName();
}
public boolean isActivated() {
return !data.isEmpty();
}
public Map<Saveable,VersionRange> getData() {
Map<Saveable,VersionRange> r = new HashMap<Saveable,VersionRange>();
for (Map.Entry<SaveableReference,VersionRange> entry : this.data.entrySet()) {
Saveable s = entry.getKey().get();
if (s != null) {
r.put(s, entry.getValue());
}
}
return r;
}
private static void remove(Saveable obj, boolean isDelete) {
Jenkins j = Jenkins.getInstance();
OldDataMonitor odm = get(j);
SecurityContext oldContext = ACL.impersonate(ACL.SYSTEM);
try {
odm.data.remove(referTo(obj));
if (isDelete && obj instanceof Job<?, ?>) {
for (Run r : ((Job<?, ?>) obj).getBuilds()) {
odm.data.remove(referTo(r));
}
}
} finally {
SecurityContextHolder.setContext(oldContext);
}
}
// Listeners to remove data here if resaved or deleted in regular Hudson usage
@Extension
public static final SaveableListener changeListener = new SaveableListener() {
@Override
public void onChange(Saveable obj, XmlFile file) {
remove(obj, false);
}
};
@Extension
public static final ItemListener itemDeleteListener = new ItemListener() {
@Override
public void onDeleted(Item item) {
remove(item, true);
}
};
@Extension
public static final RunListener<Run> runDeleteListener = new RunListener<Run>() {
@Override
public void onDeleted(Run run) {
remove(run, true);
}
};
/**
* Inform monitor that some data in a deprecated format has been loaded,
* and converted in-memory to a new structure.
* @param obj Saveable object; calling save() on this object will persist
* the data in its new format to disk.
* @param version Hudson release when the data structure changed.
*/
public static void report(Saveable obj, String version) {
OldDataMonitor odm = get(Jenkins.getInstance());
try {
SaveableReference ref = referTo(obj);
while (true) {
VersionRange vr = odm.data.get(ref);
if (vr != null && odm.data.replace(ref, vr, new VersionRange(vr, version, null))) {
break;
} else if (odm.data.putIfAbsent(ref, new VersionRange(null, version, null)) == null) {
break;
}
}
} catch (IllegalArgumentException ex) {
LOGGER.log(Level.WARNING, "Bad parameter given to OldDataMonitor", ex);
}
}
/**
* Inform monitor that some data in a deprecated format has been loaded, during
* XStream unmarshalling when the Saveable containing this object is not available.
* @param context XStream unmarshalling context
* @param version Hudson release when the data structure changed.
*/
public static void report(UnmarshallingContext context, String version) {
RobustReflectionConverter.addErrorInContext(context, new ReportException(version));
}
private static class ReportException extends Exception {
private String version;
private ReportException(String version) {
this.version = version;
}
}
/**
* Inform monitor that some unreadable data was found while loading.
* @param obj Saveable object; calling save() on this object will discard the unreadable data.
* @param errors Exception(s) thrown while loading, regarding the unreadable classes/fields.
*/
public static void report(Saveable obj, Collection<Throwable> errors) {
StringBuilder buf = new StringBuilder();
int i = 0;
for (Throwable e : errors) {
if (e instanceof ReportException) {
report(obj, ((ReportException)e).version);
} else {
if (++i > 1) buf.append(", ");
buf.append(e.getClass().getSimpleName()).append(": ").append(e.getMessage());
}
}
if (buf.length() == 0) return;
Jenkins j = Jenkins.getInstanceOrNull();
if (j == null) { // Need this path, at least for unit tests, but also in case of very broken startup
// Startup failed, something is very broken, so report what we can.
for (Throwable t : errors) {
LOGGER.log(Level.WARNING, "could not read " + obj + " (and Jenkins did not start up)", t);
}
return;
}
OldDataMonitor odm = get(j);
SaveableReference ref = referTo(obj);
while (true) {
VersionRange vr = odm.data.get(ref);
if (vr != null && odm.data.replace(ref, vr, new VersionRange(vr, null, buf.toString()))) {
break;
} else if (odm.data.putIfAbsent(ref, new VersionRange(null, null, buf.toString())) == null) {
break;
}
}
}
public static class VersionRange {
private static VersionNumber currentVersion = Jenkins.getVersion();
final VersionNumber min;
final VersionNumber max;
final boolean single;
final public String extra;
public VersionRange(VersionRange previous, String version, String extra) {
if (previous == null) {
min = max = version != null ? new VersionNumber(version) : null;
this.single = true;
this.extra = extra;
} else if (version == null) {
min = previous.min;
max = previous.max;
single = previous.single;
this.extra = extra;
} else {
VersionNumber ver = new VersionNumber(version);
if (previous.min == null || ver.isOlderThan(previous.min)) {
this.min = ver;
} else {
this.min = previous.min;
}
if (previous.max == null || ver.isNewerThan(previous.max)) {
this.max = ver;
} else {
this.max = previous.max;
}
this.single = this.max.isNewerThan(this.min);
this.extra = extra;
}
}
@Override
public String toString() {
return min==null ? "" : min.toString() + (single ? "" : " - " + max.toString());
}
/**
* Does this version range contain a version more than the given number of releases ago?
* @param threshold Number of releases
* @return True if the major version# differs or the minor# differs by >= threshold
*/
public boolean isOld(int threshold) {
return currentVersion != null && min != null && (currentVersion.digit(0) > min.digit(0)
|| (currentVersion.digit(0) == min.digit(0)
&& currentVersion.digit(1) - min.digit(1) >= threshold));
}
}
/**
* Sorted list of unique max-versions in the data set. For select list in jelly.
*/
@Restricted(NoExternalUse.class)
public Iterator<VersionNumber> getVersionList() {
TreeSet<VersionNumber> set = new TreeSet<VersionNumber>();
for (VersionRange vr : data.values()) {
if (vr.max != null) {
set.add(vr.max);
}
}
return set.iterator();
}
/**
* Depending on whether the user said "yes" or "no", send him to the right place.
*/
@RequirePOST
public HttpResponse doAct(StaplerRequest req, StaplerResponse rsp) throws IOException {
if (req.hasParameter("no")) {
disable(true);
return HttpResponses.redirectViaContextPath("/manage");
} else {
return new HttpRedirect("manage");
}
}
/**
* Save all or some of the files to persist data in the new forms.
* Remove those items from the data map.
*/
@RequirePOST
public HttpResponse doUpgrade(StaplerRequest req, StaplerResponse rsp) {
final String thruVerParam = req.getParameter("thruVer");
final VersionNumber thruVer = thruVerParam.equals("all") ? null : new VersionNumber(thruVerParam);
saveAndRemoveEntries(new Predicate<Map.Entry<SaveableReference, VersionRange>>() {
@Override
public boolean apply(Map.Entry<SaveableReference, VersionRange> entry) {
VersionNumber version = entry.getValue().max;
return version != null && (thruVer == null || !version.isNewerThan(thruVer));
}
});
return HttpResponses.forwardToPreviousPage();
}
/**
* Save all files containing only unreadable data (no data upgrades), which discards this data.
* Remove those items from the data map.
*/
@RequirePOST
public HttpResponse doDiscard(StaplerRequest req, StaplerResponse rsp) {
saveAndRemoveEntries( new Predicate<Map.Entry<SaveableReference,VersionRange>>() {
@Override
public boolean apply(Map.Entry<SaveableReference, VersionRange> entry) {
return entry.getValue().max == null;
}
});
return HttpResponses.forwardToPreviousPage();
}
private void saveAndRemoveEntries(Predicate<Map.Entry<SaveableReference, VersionRange>> matchingPredicate) {
/*
* Note that there a race condition here: we acquire the lock and get localCopy which includes some
* project (say); then we go through our loop and save that project; then someone POSTs a new
* config.xml for the project with some old data, causing remove to be called and the project to be
* added to data (in the new version); then we hit the end of this method and the project is removed
* from data again, even though it again has old data.
*
* In practice this condition is extremely unlikely, and not a major problem even if it
* does occur: just means the user will be prompted to discard less than they should have been (and
* would see the warning again after next restart).
*/
List<SaveableReference> removed = new ArrayList<SaveableReference>();
for (Map.Entry<SaveableReference,VersionRange> entry : data.entrySet()) {
if (matchingPredicate.apply(entry)) {
Saveable s = entry.getKey().get();
if (s != null) {
try {
s.save();
} catch (Exception x) {
LOGGER.log(Level.WARNING, "failed to save " + s, x);
}
}
removed.add(entry.getKey());
}
}
data.keySet().removeAll(removed);
}
public HttpResponse doIndex(StaplerResponse rsp) throws IOException {
return new HttpRedirect("manage");
}
/** Reference to a saveable object that need not actually hold it in heap. */
private interface SaveableReference {
@CheckForNull Saveable get();
// must also define equals, hashCode
}
private static SaveableReference referTo(Saveable s) {
if (s instanceof Run) {
Job parent = ((Run) s).getParent();
if (Jenkins.getInstance().getItemByFullName(parent.getFullName()) == parent) {
return new RunSaveableReference((Run) s);
}
}
return new SimpleSaveableReference(s);
}
private static final class SimpleSaveableReference implements SaveableReference {
private final Saveable instance;
SimpleSaveableReference(Saveable instance) {
this.instance = instance;
}
@Override public Saveable get() {
return instance;
}
@Override public int hashCode() {
return instance.hashCode();
}
@Override public boolean equals(Object obj) {
return obj instanceof SimpleSaveableReference && instance.equals(((SimpleSaveableReference) obj).instance);
}
}
// could easily make an ItemSaveableReference, but Jenkins holds all these strongly, so why bother
private static final class RunSaveableReference implements SaveableReference {
private final String id;
RunSaveableReference(Run<?,?> r) {
id = r.getExternalizableId();
}
@Override public Saveable get() {
try {
return Run.fromExternalizableId(id);
} catch (IllegalArgumentException x) {
// Typically meaning the job or build was since deleted.
LOGGER.log(Level.FINE, null, x);
return null;
}
}
@Override public int hashCode() {
return id.hashCode();
}
@Override public boolean equals(Object obj) {
return obj instanceof RunSaveableReference && id.equals(((RunSaveableReference) obj).id);
}
}
@Extension @Symbol("oldData")
public static class ManagementLinkImpl extends ManagementLink {
@Override
public String getIconFileName() {
return "document.png";
}
@Override
public String getUrlName() {
return "administrativeMonitor/OldData/";
}
@Override
public String getDescription() {
return Messages.OldDataMonitor_Description();
}
public String getDisplayName() {
return Messages.OldDataMonitor_DisplayName();
}
}
}