/******************************************************************************* * * Copyright (c) 2004-2012 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, Winston Prakash, Daniel Dyer, Tom Huybrechts * *******************************************************************************/ package hudson.model; import hudson.BulkChange; import hudson.Functions; import hudson.Util; import hudson.XmlFile; import hudson.cli.declarative.CLIMethod; import hudson.cli.declarative.CLIResolver; import hudson.model.listeners.ItemListener; import hudson.model.listeners.SaveableListener; import hudson.security.ACL; import hudson.security.AccessControlled; import hudson.security.Permission; import hudson.util.AtomicFileWriter; import hudson.util.IOException2; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.util.Collection; import java.util.Random; import javax.servlet.ServletException; import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; import org.eclipse.hudson.security.HudsonSecurityEntitiesHolder; import org.eclipse.hudson.security.team.TeamManager; import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.CmdLineException; import org.kohsuke.stapler.HttpDeletable; import org.kohsuke.stapler.Stapler; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.WebMethod; import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.export.ExportedBean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * 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 { static final Logger LOGGER = LoggerFactory.getLogger(AbstractItem.class); /** * Project name. */ protected /*final*/ transient String name; /** * Project description. Can be HTML. */ protected volatile String description; private transient ItemGroup parent; protected AbstractItem(ItemGroup parent, String name) { this.parent = parent; if ((parent instanceof Hudson) && (Hudson.getInstance() != null) && (Hudson.getInstance().isTeamManagementEnabled())) { // A job created by itemGroupMixin with an explicit team already // has a qualified name and has been added to the team TeamManager teamManager = Hudson.getInstance().getTeamManager(); if (teamManager.findJobOwnerTeam(name) == null) name = teamManager.getTeamQualifiedJobName(name); } 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 Messages.AbstractItem_Pronoun(); } @Exported public String getDisplayName() { return getName(); } public File getRootDir() { return (parent != null ? parent.getRootDirFor(this) : Hudson.getInstance().getRootDir()); } public ItemGroup getParent() { assert parent != null; 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(); } /** * Just update {@link #name} without performing the rename operation, which * would involve copying files and etc. */ protected void doSetName(String name) { this.name = name; } /** * Ad additional action which should be performed before the item will be * renamed. It's possible to add some logic in the subclasses. * * @param oldName old item name. * @param newName new item name. * @throws java.io.IOException if item couldn't be saved. */ protected void performBeforeItemRenaming(String oldName, String newName) throws IOException { } /** * 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(String newName) throws IOException { // always synchronize from bigger objects first final ItemGroup parent = getParent(); synchronized (parent) { synchronized (this) { // sanity check if (newName == null) { throw new IllegalArgumentException("New name is not given"); } // noop? if (this.name.equals(newName)) { return; } Item existing = parent.getItem(newName); if (existing != null && existing != this) // 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"); } String oldName = this.name; performBeforeItemRenaming(oldName, newName); File oldRoot = this.getRootDir(); doSetName(newName); File newRoot = this.getRootDir(); boolean success = false; try {// rename data files try { Util.moveDirectory(oldRoot, newRoot); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } success = true; } finally { // if failed, back out the rename. if (!success) { doSetName(oldName); } } callOnRenamed(newName, parent, oldName); for (ItemListener l : ItemListener.all()) { try { l.onRenamed(this, oldName, newName); } catch (Exception e) { LOGGER.warn("Exception in ItemListener.onRename", e); } } save(); } } } /** * A pointless function to work around what appears to be a HotSpot problem. * See HUDSON-5756 and bug 6933067 on BugParade for more details. */ private void callOnRenamed(String newName, ItemGroup parent, String oldName) throws IOException { try { parent.onRenamed(this, oldName, newName); } catch (AbstractMethodError _) { // ignore } } /** * 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 + " \u00BB " + getDisplayName(); } } /** * Called right after when a {@link Item} is loaded from disk. This is an * opporunity to do a post load processing. */ public void onLoad(ItemGroup<? extends Item> parent, String name) throws IOException { this.parent = parent; doSetName(name); deleteLock = new Object(); } /** * 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. */ public void onCopiedFrom(Item src) { } public final String getUrl() { // try to stick to the current view if possible StaplerRequest req = Stapler.getCurrentRequest(); if (req != null) { String seed = Functions.getNearestAncestorUrl(req, this); if (seed != null) { // trim off the context path portion and leading '/', but add trailing '/' return seed.substring(Functions.getRequestRootPath(req).length() + 1) + '/'; } } // otherwise compute the path normally return getParent().getUrl() + getShortUrl(); } public String getShortUrl() { return getParent().getUrlChildPrefix() + '/' + Util.rawEncode(getName()) + '/'; } public String getSearchUrl() { return getShortUrl(); } @Exported(visibility = 999, name = "url") public final String getAbsoluteUrl() { StaplerRequest request = Stapler.getCurrentRequest(); if (request == null) { throw new IllegalStateException("Not processing a HTTP request"); } return Util.encode(Hudson.getInstance().getRootUrl() + getUrl()); } /** * Remote API access. */ public final Api getApi() { return new Api(this); } /** * Returns the {@link ACL} for this object. */ public ACL getACL() { return HudsonSecurityEntitiesHolder.getHudsonSecurityManager().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 Hudson.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. */ @CLIMethod(name = "delete-job") public void doDoDelete(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException, InterruptedException { requirePOST(); delete(); if (rsp != null) // null for CLI { rsp.sendRedirect2(req.getContextPath() + "/" + getParent().getUrl()); } } 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); } } private transient Object deleteLock = new Object(); /** * Get delete lock. Used as follows: * * <pre> * synchronized(project.getDeleteLock()) { * if (!project.isDeleted()) { * // do something with project root folder * } * } * </pre> * @return delete lock that can be used to prevent deletion while performing * operations requiring access to item (job) folder. Should be obtained * before any other lock/synchronized to avoid deadlock. * @since 3.3.0 * @see #isDeleted */ public Object getDeleteLock() { return deleteLock; } /** * Deletes this item. */ public void delete() throws IOException, InterruptedException { final ItemGroup group = getParent(); // Obtain delete lock synchronized (getDeleteLock()) { // Lock parent, and then 'this' before deleting. synchronized (group) { synchronized (this) { checkPermission(DELETE); performDelete(); try { invokeOnDeleted(); } catch (AbstractMethodError e) { // ignore } Hudson.getInstance().rebuildDependencyGraph(); } } } } /** * A pointless function to work around what appears to be a HotSpot problem. * See HUDSON-5756 and bug 6933067 on BugParade for more details. */ private void invokeOnDeleted() throws IOException { getParent().onDeleted(this); } /** * Attempt to move root directory out of the way of new job creation * but within the same parent dir. Leaves a better diagnostic trace * and stays within current file system. * Must not throw. * @return File; moved root dir if successful, otherwise original root dir */ private void trySidelineJobDir(File rootDir) { File newDir = null; for (int retry = 0; retry < 5; retry++) { Random r = new Random(); int n = r.nextInt(); StringBuilder sb = new StringBuilder("tmp#"); // Can't be a job name sb.append(n); sb.append('_'); sb.append(rootDir.getName()); if (sb.length() > 255) { sb.setLength(255); } File dir = new File(rootDir.getParentFile(), sb.toString()); if (!dir.exists()) { newDir = dir; break; } } if (newDir != null) { try { Files.move(rootDir.toPath(), newDir.toPath(), StandardCopyOption.ATOMIC_MOVE ,StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { LOGGER.warn("Move job folder unsuccessful "+rootDir.getAbsolutePath(), e); return; } LOGGER.info("Job folder successfully moved from "+rootDir.getAbsolutePath()+" to "+newDir.getAbsolutePath()); } else { LOGGER.warn("Move job folder unsuccessful "+rootDir.getAbsolutePath()); } } private transient volatile boolean deleted; public boolean isDeleted() { return deleted; } /** * Does the real job of deleting the item. */ protected void performDelete() throws IOException, InterruptedException { if (!getConfigFile().doDelete()) { throw new IOException(getRootDir().getAbsolutePath()+"/config.xml can't be deleted"); } // delete must succeed beyond this point final File rootDir = getRootDir(); deleted = true; new Thread(new Runnable() { @Override public void run() { try { Util.deleteRecursive(rootDir); LOGGER.info("Job is deleted at "+rootDir.getAbsolutePath()); } catch (Exception e) { LOGGER.warn("Delete job folder failed "+rootDir.getAbsolutePath()+" because "+e.getMessage()); // Bug 432569 - If folder can't be deleted, leaves job in half-deleted state trySidelineJobDir(rootDir); } } }, "Deleting "+getName()).start(); } /** * 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 checkPermission(EXTENDED_READ); rsp.setContentType("application/xml"); getConfigFile().writeRawTo(rsp.getOutputStream()); return; } if (req.getMethod().equals("POST")) { // submission checkPermission(CONFIGURE); XmlFile configXmlFile = getConfigFile(); AtomicFileWriter out = new AtomicFileWriter(configXmlFile.getFile()); try { try { // this allows us to use UTF-8 for storing data, // plus it checks any well-formedness issue in the submitted // data Transformer t = TransformerFactory.newInstance() .newTransformer(); t.transform(new StreamSource(req.getReader()), new StreamResult(out)); out.close(); } catch (TransformerException e) { throw new IOException2("Failed to persist configuration.xml", e); } // try to reflect the changes by reloading new XmlFile(Items.XSTREAM, out.getTemporaryFile()).unmarshal(this); onLoad(getParent(), getRootDir().getName()); // if everything went well, commit this new version out.commit(); } finally { out.abort(); // don't leave anything behind } return; } // huh? rsp.sendError(SC_BAD_REQUEST); } public String toString() { return getClass().getSimpleName() + '[' + getFullName() + ']'; } /** * Used for CLI binding. */ @CLIResolver public static AbstractItem resolveForCLI( @Argument(required = true, metaVar = "NAME", usage = "Job name") String name) throws CmdLineException { AbstractItem item = Hudson.getInstance().getItemByFullName(name, AbstractItem.class); if (item == null) { if (AbstractProject.findNearest(name) != null) { throw new CmdLineException(null, Messages.AbstractItem_NoSuchJobExists2(name, AbstractProject.findNearest(name).getFullName())); } else { throw new CmdLineException(null, Messages.AbstractItem_NoSuchJobExists(name)); } } return item; } }