/*
* The MIT License
*
* Copyright (c) 2004-2011, Sun Microsystems, Inc., Kohsuke Kawaguchi,
* Daniel Dyer, Tom Huybrechts, Yahoo!, 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 hudson.model;
import com.infradna.tool.bridge_method_injector.WithBridgeMethods;
import hudson.AbortException;
import hudson.XmlFile;
import hudson.Util;
import hudson.Functions;
import hudson.BulkChange;
import hudson.cli.declarative.CLIResolver;
import hudson.model.listeners.ItemListener;
import hudson.model.listeners.SaveableListener;
import hudson.security.AccessControlled;
import hudson.security.Permission;
import hudson.security.ACL;
import hudson.util.AlternativeUiTextProvider;
import hudson.util.AlternativeUiTextProvider.Message;
import hudson.util.AtomicFileWriter;
import hudson.util.IOUtils;
import hudson.util.Secret;
import jenkins.model.DirectlyModifiableTopLevelItemGroup;
import jenkins.model.Jenkins;
import jenkins.security.NotReallyRoleSensitiveCallable;
import org.acegisecurity.Authentication;
import jenkins.util.xml.XMLUtils;
import org.apache.tools.ant.taskdefs.Copy;
import org.apache.tools.ant.types.FileSet;
import org.kohsuke.stapler.WebMethod;
import org.kohsuke.stapler.export.Exported;
import org.kohsuke.stapler.export.ExportedBean;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Collection;
import java.util.List;
import java.util.ListIterator;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nonnull;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.HttpDeletable;
import org.kohsuke.args4j.Argument;
import org.kohsuke.args4j.CmdLineException;
import org.kohsuke.stapler.interceptor.RequirePOST;
import org.xml.sax.SAXException;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.xml.transform.Source;
import javax.xml.transform.TransformerException;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import org.apache.commons.io.FileUtils;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.Ancestor;
/**
* Partial default implementation of {@link Item}.
*
* @author Kohsuke Kawaguchi
*/
// Item doesn't necessarily have to be Actionable, but
// Java doesn't let multiple inheritance.
@ExportedBean
public abstract class AbstractItem extends Actionable implements Item, HttpDeletable, AccessControlled, DescriptorByNameOwner {
private static final Logger LOGGER = Logger.getLogger(AbstractItem.class.getName());
/**
* Project name.
*/
protected /*final*/ transient String name;
/**
* Project description. Can be HTML.
*/
protected volatile String description;
private transient ItemGroup parent;
protected String displayName;
protected AbstractItem(ItemGroup parent, String name) {
this.parent = parent;
doSetName(name);
}
public void onCreatedFromScratch() {
// noop
}
@Exported(visibility=999)
public String getName() {
return name;
}
/**
* Get the term used in the UI to represent this kind of
* {@link Item}. Must start with a capital letter.
*/
public String getPronoun() {
return AlternativeUiTextProvider.get(PRONOUN, this, Messages.AbstractItem_Pronoun());
}
@Exported
/**
* @return The display name of this object, or if it is not set, the name
* of the object.
*/
public String getDisplayName() {
if(null!=displayName) {
return displayName;
}
// if the displayName is not set, then return the name as we use to do
return getName();
}
@Exported
/**
* This is intended to be used by the Job configuration pages where
* we want to return null if the display name is not set.
* @return The display name of this object or null if the display name is not
* set
*/
public String getDisplayNameOrNull() {
return displayName;
}
/**
* This method exists so that the Job configuration pages can use
* getDisplayNameOrNull so that nothing is shown in the display name text
* box if the display name is not set.
* @param displayName
* @throws IOException
*/
public void setDisplayNameOrNull(String displayName) throws IOException {
setDisplayName(displayName);
}
public void setDisplayName(String displayName) throws IOException {
this.displayName = Util.fixEmptyAndTrim(displayName);
save();
}
public File getRootDir() {
return getParent().getRootDirFor(this);
}
/**
* This bridge method is to maintain binary compatibility with {@link TopLevelItem#getParent()}.
*/
@WithBridgeMethods(value=Jenkins.class,castRequired=true)
@Override public @Nonnull ItemGroup getParent() {
if (parent == null) {
throw new IllegalStateException("no parent set on " + getClass().getName() + "[" + name + "]");
}
return parent;
}
/**
* Gets the project description HTML.
*/
@Exported
public String getDescription() {
return description;
}
/**
* Sets the project description HTML.
*/
public void setDescription(String description) throws IOException {
this.description = description;
save();
ItemListener.fireOnUpdated(this);
}
/**
* Just update {@link #name} without performing the rename operation,
* which would involve copying files and etc.
*/
protected void doSetName(String name) {
this.name = name;
}
/**
* Renames this item.
* Not all the Items need to support this operation, but if you decide to do so,
* you can use this method.
*/
protected void renameTo(final String newName) throws IOException {
// always synchronize from bigger objects first
final ItemGroup parent = getParent();
String oldName = this.name;
String oldFullName = getFullName();
synchronized (parent) {
synchronized (this) {
// sanity check
if (newName == null)
throw new IllegalArgumentException("New name is not given");
// noop?
if (this.name.equals(newName))
return;
// the test to see if the project already exists or not needs to be done in escalated privilege
// to avoid overwriting
ACL.impersonate(ACL.SYSTEM,new NotReallyRoleSensitiveCallable<Void,IOException>() {
final Authentication user = Jenkins.getAuthentication();
@Override
public Void call() throws IOException {
Item existing = parent.getItem(newName);
if (existing != null && existing!=AbstractItem.this) {
if (existing.getACL().hasPermission(user,Item.DISCOVER))
// the look up is case insensitive, so we need "existing!=this"
// to allow people to rename "Foo" to "foo", for example.
// see http://www.nabble.com/error-on-renaming-project-tt18061629.html
throw new IllegalArgumentException("Job " + newName + " already exists");
else {
// can't think of any real way to hide this, but at least the error message could be vague.
throw new IOException("Unable to rename to " + newName);
}
}
return null;
}
});
File oldRoot = this.getRootDir();
doSetName(newName);
File newRoot = this.getRootDir();
boolean success = false;
try {// rename data files
boolean interrupted = false;
boolean renamed = false;
// try to rename the job directory.
// this may fail on Windows due to some other processes
// accessing a file.
// so retry few times before we fall back to copy.
for (int retry = 0; retry < 5; retry++) {
if (oldRoot.renameTo(newRoot)) {
renamed = true;
break; // succeeded
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
// process the interruption later
interrupted = true;
}
}
if (interrupted)
Thread.currentThread().interrupt();
if (!renamed) {
// failed to rename. it must be that some lengthy
// process is going on
// to prevent a rename operation. So do a copy. Ideally
// we'd like to
// later delete the old copy, but we can't reliably do
// so, as before the VM
// shuts down there might be a new job created under the
// old name.
Copy cp = new Copy();
cp.setProject(new org.apache.tools.ant.Project());
cp.setTodir(newRoot);
FileSet src = new FileSet();
src.setDir(oldRoot);
cp.addFileset(src);
cp.setOverwrite(true);
cp.setPreserveLastModified(true);
cp.setFailOnError(false); // keep going even if
// there's an error
cp.execute();
// try to delete as much as possible
try {
Util.deleteRecursive(oldRoot);
} catch (IOException e) {
// but ignore the error, since we expect that
e.printStackTrace();
}
}
success = true;
} finally {
// if failed, back out the rename.
if (!success)
doSetName(oldName);
}
try {
parent.onRenamed(this, oldName, newName);
} catch (AbstractMethodError _) {
// ignore
}
}
}
ItemListener.fireLocationChange(this, oldFullName);
}
/**
* Notify this item it's been moved to another location, replaced by newItem (might be the same object, but not guaranteed).
* This method is executed <em>after</em> the item root directory has been moved to it's new location.
* <p>
* Derived classes can override this method to add some specific behavior on move, but have to call parent method
* so the item is actually setup within it's new parent.
*
* @see hudson.model.Items#move(AbstractItem, jenkins.model.DirectlyModifiableTopLevelItemGroup)
*/
public void movedTo(DirectlyModifiableTopLevelItemGroup destination, AbstractItem newItem, File destDir) throws IOException {
newItem.onLoad(destination, name);
}
/**
* Gets all the jobs that this {@link Item} contains as descendants.
*/
public abstract Collection<? extends Job> getAllJobs();
public final String getFullName() {
String n = getParent().getFullName();
if(n.length()==0) return getName();
else return n+'/'+getName();
}
public final String getFullDisplayName() {
String n = getParent().getFullDisplayName();
if(n.length()==0) return getDisplayName();
else return n+" » "+getDisplayName();
}
/**
* Gets the display name of the current item relative to the given group.
*
* @since 1.515
* @param p the ItemGroup used as point of reference for the item
* @return
* String like "foo » bar"
*/
public String getRelativeDisplayNameFrom(ItemGroup p) {
return Functions.getRelativeDisplayNameFrom(this, p);
}
/**
* This method only exists to disambiguate {@link #getRelativeNameFrom(ItemGroup)} and {@link #getRelativeNameFrom(Item)}
* @since 1.512
* @see #getRelativeNameFrom(ItemGroup)
*/
public String getRelativeNameFromGroup(ItemGroup p) {
return getRelativeNameFrom(p);
}
/**
* @param p
* The ItemGroup instance used as context to evaluate the relative name of this AbstractItem
* @return
* The name of the current item, relative to p.
* Nested ItemGroups are separated by / character.
*/
public String getRelativeNameFrom(ItemGroup p) {
return Functions.getRelativeNameFrom(this, p);
}
public String getRelativeNameFrom(Item item) {
return getRelativeNameFrom(item.getParent());
}
/**
* Called right after when a {@link Item} is loaded from disk.
* This is an opportunity to do a post load processing.
*/
public void onLoad(ItemGroup<? extends Item> parent, String name) throws IOException {
this.parent = parent;
doSetName(name);
}
/**
* When a {@link Item} is copied from existing one,
* the files are first copied on the file system,
* then it will be loaded, then this method will be invoked
* to perform any implementation-specific work.
*
* <p>
*
*
* @param src
* Item from which it's copied from. The same type as {@code this}. Never null.
*/
public void onCopiedFrom(Item src) {
}
public final String getUrl() {
// try to stick to the current view if possible
StaplerRequest req = Stapler.getCurrentRequest();
String shortUrl = getShortUrl();
String uri = req == null ? null : req.getRequestURI();
if (req != null) {
String seed = Functions.getNearestAncestorUrl(req,this);
LOGGER.log(Level.FINER, "seed={0} for {1} from {2}", new Object[] {seed, this, uri});
if(seed!=null) {
// trim off the context path portion and leading '/', but add trailing '/'
return seed.substring(req.getContextPath().length()+1)+'/';
}
List<Ancestor> ancestors = req.getAncestors();
if (!ancestors.isEmpty()) {
Ancestor last = ancestors.get(ancestors.size() - 1);
if (last.getObject() instanceof View) {
View view = (View) last.getObject();
if (view.getOwnerItemGroup() == getParent() && !view.isDefault()) {
// Showing something inside a view, so should use that as the base URL.
String base = last.getUrl().substring(req.getContextPath().length() + 1) + '/';
LOGGER.log(Level.FINER, "using {0}{1} for {2} from {3}", new Object[] {base, shortUrl, this, uri});
return base + shortUrl;
} else {
LOGGER.log(Level.FINER, "irrelevant {0} for {1} from {2}", new Object[] {view.getViewName(), this, uri});
}
} else {
LOGGER.log(Level.FINER, "inapplicable {0} for {1} from {2}", new Object[] {last.getObject(), this, uri});
}
} else {
LOGGER.log(Level.FINER, "no ancestors for {0} from {1}", new Object[] {this, uri});
}
} else {
LOGGER.log(Level.FINER, "no current request for {0}", this);
}
// otherwise compute the path normally
String base = getParent().getUrl();
LOGGER.log(Level.FINER, "falling back to {0}{1} for {2} from {3}", new Object[] {base, shortUrl, this, uri});
return base + shortUrl;
}
public String getShortUrl() {
String prefix = getParent().getUrlChildPrefix();
String subdir = Util.rawEncode(getName());
return prefix.equals(".") ? subdir + '/' : prefix + '/' + subdir + '/';
}
public String getSearchUrl() {
return getShortUrl();
}
@Exported(visibility=999,name="url")
public final String getAbsoluteUrl() {
String r = Jenkins.getInstance().getRootUrl();
if(r==null)
throw new IllegalStateException("Root URL isn't configured yet. Cannot compute absolute URL.");
return Util.encode(r+getUrl());
}
/**
* Remote API access.
*/
public final Api getApi() {
return new Api(this);
}
/**
* Returns the {@link ACL} for this object.
*/
public ACL getACL() {
return Jenkins.getInstance().getAuthorizationStrategy().getACL(this);
}
/**
* Short for {@code getACL().checkPermission(p)}
*/
public void checkPermission(Permission p) {
getACL().checkPermission(p);
}
/**
* Short for {@code getACL().hasPermission(p)}
*/
public boolean hasPermission(Permission p) {
return getACL().hasPermission(p);
}
/**
* Save the settings to a file.
*/
public synchronized void save() throws IOException {
if(BulkChange.contains(this)) return;
getConfigFile().write(this);
SaveableListener.fireOnChange(this, getConfigFile());
}
public final XmlFile getConfigFile() {
return Items.getConfigFile(this);
}
public Descriptor getDescriptorByName(String className) {
return Jenkins.getInstance().getDescriptorByName(className);
}
/**
* Accepts the new description.
*/
public synchronized void doSubmitDescription( StaplerRequest req, StaplerResponse rsp ) throws IOException, ServletException {
checkPermission(CONFIGURE);
setDescription(req.getParameter("description"));
rsp.sendRedirect("."); // go to the top page
}
/**
* Deletes this item.
* Note on the funny name: for reasons of historical compatibility, this URL is {@code /doDelete}
* since it predates {@code <l:confirmationLink>}. {@code /delete} goes to a Jelly page
* which should now be unused by core but is left in case plugins are still using it.
*/
@RequirePOST
public void doDoDelete( StaplerRequest req, StaplerResponse rsp ) throws IOException, ServletException, InterruptedException {
delete();
if (req == null || rsp == null) { // CLI
return;
}
List<Ancestor> ancestors = req.getAncestors();
ListIterator<Ancestor> it = ancestors.listIterator(ancestors.size());
String url = getParent().getUrl(); // fallback but we ought to get to Jenkins.instance at the root
while (it.hasPrevious()) {
Object a = it.previous().getObject();
if (a instanceof View) {
url = ((View) a).getUrl();
break;
} else if (a instanceof ViewGroup && a != this) {
url = ((ViewGroup) a).getUrl();
break;
}
}
rsp.sendRedirect2(req.getContextPath() + '/' + url);
}
public void delete( StaplerRequest req, StaplerResponse rsp ) throws IOException, ServletException {
try {
doDoDelete(req,rsp);
} catch (InterruptedException e) {
// TODO: allow this in Stapler
throw new ServletException(e);
}
}
/**
* Deletes this item.
*
* <p>
* Any exception indicates the deletion has failed, but {@link AbortException} would prevent the caller
* from showing the stack trace. This
*/
public void delete() throws IOException, InterruptedException {
checkPermission(DELETE);
synchronized (this) { // could just make performDelete synchronized but overriders might not honor that
performDelete();
} // JENKINS-19446: leave synch block, but JENKINS-22001: still notify synchronously
getParent().onDeleted(AbstractItem.this);
Jenkins.getInstance().rebuildDependencyGraphAsync();
}
/**
* Does the real job of deleting the item.
*/
protected void performDelete() throws IOException, InterruptedException {
getConfigFile().delete();
Util.deleteRecursive(getRootDir());
}
/**
* Accepts <tt>config.xml</tt> submission, as well as serve it.
*/
@WebMethod(name = "config.xml")
public void doConfigDotXml(StaplerRequest req, StaplerResponse rsp)
throws IOException {
if (req.getMethod().equals("GET")) {
// read
rsp.setContentType("application/xml");
writeConfigDotXml(rsp.getOutputStream());
return;
}
if (req.getMethod().equals("POST")) {
// submission
updateByXml((Source)new StreamSource(req.getReader()));
return;
}
// huh?
rsp.sendError(SC_BAD_REQUEST);
}
static final Pattern SECRET_PATTERN = Pattern.compile(">(" + Secret.ENCRYPTED_VALUE_PATTERN + ")<");
/**
* Writes {@code config.xml} to the specified output stream.
* The user must have at least {@link #EXTENDED_READ}.
* If he lacks {@link #CONFIGURE}, then any {@link Secret}s detected will be masked out.
*/
@Restricted(NoExternalUse.class)
public void writeConfigDotXml(OutputStream os) throws IOException {
checkPermission(EXTENDED_READ);
XmlFile configFile = getConfigFile();
if (hasPermission(CONFIGURE)) {
IOUtils.copy(configFile.getFile(), os);
} else {
String encoding = configFile.sniffEncoding();
String xml = FileUtils.readFileToString(configFile.getFile(), encoding);
Matcher matcher = SECRET_PATTERN.matcher(xml);
StringBuffer cleanXml = new StringBuffer();
while (matcher.find()) {
if (Secret.decrypt(matcher.group(1)) != null) {
matcher.appendReplacement(cleanXml, ">********<");
}
}
matcher.appendTail(cleanXml);
org.apache.commons.io.IOUtils.write(cleanXml.toString(), os, encoding);
}
}
/**
* @deprecated as of 1.473
* Use {@link #updateByXml(Source)}
*/
@Deprecated
public void updateByXml(StreamSource source) throws IOException {
updateByXml((Source)source);
}
/**
* Updates an Item by its XML definition.
* @param source source of the Item's new definition.
* The source should be either a <code>StreamSource</code> or a <code>SAXSource</code>, other
* sources may not be handled.
* @since 1.473
*/
public void updateByXml(Source source) throws IOException {
checkPermission(CONFIGURE);
XmlFile configXmlFile = getConfigFile();
final AtomicFileWriter out = new AtomicFileWriter(configXmlFile.getFile());
try {
try {
XMLUtils.safeTransform(source, new StreamResult(out));
out.close();
} catch (TransformerException e) {
throw new IOException("Failed to persist config.xml", e);
} catch (SAXException e) {
throw new IOException("Failed to persist config.xml", e);
}
// try to reflect the changes by reloading
Object o = new XmlFile(Items.XSTREAM, out.getTemporaryFile()).unmarshal(this);
if (o!=this) {
// ensure that we've got the same job type. extending this code to support updating
// to different job type requires destroying & creating a new job type
throw new IOException("Expecting "+this.getClass()+" but got "+o.getClass()+" instead");
}
Items.whileUpdatingByXml(new NotReallyRoleSensitiveCallable<Void,IOException>() {
@Override public Void call() throws IOException {
onLoad(getParent(), getRootDir().getName());
return null;
}
});
Jenkins.getInstance().rebuildDependencyGraphAsync();
// if everything went well, commit this new version
out.commit();
SaveableListener.fireOnChange(this, getConfigFile());
} finally {
out.abort(); // don't leave anything behind
}
}
/**
* Reloads this job from the disk.
*
* Exposed through CLI as well.
*
* TODO: think about exposing this to UI
*
* @since 1.556
*/
@RequirePOST
public void doReload() throws IOException {
checkPermission(CONFIGURE);
// try to reflect the changes by reloading
getConfigFile().unmarshal(this);
Items.whileUpdatingByXml(new NotReallyRoleSensitiveCallable<Void, IOException>() {
@Override
public Void call() throws IOException {
onLoad(getParent(), getRootDir().getName());
return null;
}
});
Jenkins.getInstance().rebuildDependencyGraphAsync();
SaveableListener.fireOnChange(this, getConfigFile());
}
/**
* {@inheritDoc}
*/
@Override
public String getSearchName() {
// the search name of abstract items should be the name and not display name.
// this will make suggestions use the names and not the display name
// so that the links will 302 directly to the thing the user was finding
return getName();
}
@Override public String toString() {
return super.toString() + '[' + (parent != null ? getFullName() : "?/" + name) + ']';
}
/**
* Used for CLI binding.
*/
@CLIResolver
public static AbstractItem resolveForCLI(
@Argument(required=true,metaVar="NAME",usage="Job name") String name) throws CmdLineException {
// TODO can this (and its pseudo-override in AbstractProject) share code with GenericItemOptionHandler, used for explicit CLICommand’s rather than CLIMethod’s?
AbstractItem item = Jenkins.getInstance().getItemByFullName(name, AbstractItem.class);
if (item==null) {
AbstractProject project = AbstractProject.findNearest(name);
throw new CmdLineException(null, project == null ? Messages.AbstractItem_NoSuchJobExistsWithoutSuggestion(name)
: Messages.AbstractItem_NoSuchJobExists(name, project.getFullName()));
}
return item;
}
/**
* Replaceable pronoun of that points to a job. Defaults to "Job"/"Project" depending on the context.
*/
public static final Message<AbstractItem> PRONOUN = new Message<AbstractItem>();
}