/* * The MIT License * * Copyright (c) 2014-2015, Matthew DeTullio * * 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 com.github.mjdetullio.jenkins.plugins.multibranch; import hudson.Extension; import hudson.XmlFile; import hudson.cli.declarative.CLIMethod; import hudson.model.AbstractBuild; import hudson.model.AbstractProject; import hudson.model.Descriptor; import hudson.model.Item; import hudson.model.ItemGroup; import hudson.model.Items; import hudson.model.Job; import hudson.model.Result; import hudson.model.Run; import hudson.model.Saveable; import hudson.model.TopLevelItem; import hudson.model.View; import hudson.model.ViewDescriptor; import hudson.model.listeners.ItemListener; import hudson.model.listeners.SaveableListener; import hudson.scm.NullSCM; import hudson.util.AlternativeUiTextProvider; import hudson.util.PersistedList; import jenkins.branch.MultiBranchProject; import jenkins.model.Jenkins; import jenkins.scm.api.SCMSourceOwner; import org.kohsuke.stapler.HttpRedirect; import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.interceptor.RequirePOST; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.servlet.ServletException; import javax.xml.transform.Source; import javax.xml.transform.stream.StreamSource; import java.io.File; import java.io.IOException; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; /** * @author Matthew DeTullio */ public abstract class TemplateDrivenMultiBranchProject<P extends AbstractProject<P, B> & TopLevelItem, B extends AbstractBuild<P, B>> // NOSONAR extends MultiBranchProject<P, B> implements TopLevelItem, SCMSourceOwner { private static final String CLASSNAME = TemplateDrivenMultiBranchProject.class.getName(); private static final Logger LOGGER = Logger.getLogger(CLASSNAME); private static final String UNUSED = "unused"; public static final String TEMPLATE = "template"; protected volatile boolean disabled; private PersistedList<String> disabledSubProjects; protected transient P template; // NOSONAR /** * Constructor, mandated by {@link TopLevelItem}. * * @param parent the parent of this multi-branch job. * @param name the name of the multi-branch job. */ public TemplateDrivenMultiBranchProject(ItemGroup parent, String name) { super(parent, name); init3(); } @Override public void onLoad(ItemGroup<? extends Item> parent, String name) throws IOException { super.onLoad(parent, name); init3(); } /** * Common initialization that is invoked when either a new project is created with the constructor * {@link TemplateDrivenMultiBranchProject#TemplateDrivenMultiBranchProject(ItemGroup, String)} or when a project * is loaded from disk with {@link #onLoad(ItemGroup, String)}. */ protected void init3() { if (disabledSubProjects == null) { disabledSubProjects = new PersistedList<>(this); } // Owner doesn't seem to be set when loading from XML disabledSubProjects.setOwner(this); try { XmlFile templateXmlFile = Items.getConfigFile(getTemplateDir()); if (templateXmlFile.getFile().isFile()) { /* * Do not use Items.load here, since it uses getRootDirFor(i) during onLoad, * which returns the wrong location since template would still be unset. * Instead, read the XML directly into template and then invoke onLoad. */ //noinspection unchecked template = (P) templateXmlFile.read(); template.onLoad(this, TEMPLATE); } else { /* * Don't use the factory here because newInstance calls setBranch, attempting * to save the project before template is set. That would invoke * getRootDirFor(i) and get the wrong directory to save into. */ template = newTemplate(); } // Prevent tampering if (!(template.getScm() instanceof NullSCM)) { template.setScm(new NullSCM()); } template.disable(); } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to load template project " + getTemplateDir(), e); } } /** * Should create a new project using this {@link TemplateDrivenMultiBranchProject} as the parent and * {@link TemplateDrivenMultiBranchProject#TEMPLATE} as the name. * * @return a new project to serve as the {@link #template}. */ protected abstract P newTemplate(); /** * Overrides view initialization to use BranchListView instead of AllView. * <br> * {@inheritDoc} */ @Override protected void initViews(List<View> views) throws IOException { BranchListView v = new BranchListView("All", this); v.setIncludeRegex(".*"); views.add(v); v.save(); } /** * Retrieves the template sub-project. Used by jelly views. * * @return P - the template sub-project. */ @SuppressWarnings(UNUSED) public P getTemplate() { return template; } /** * Returns the "template" directory inside the project directory. This is the template project's directory. * * @return File - "template" directory inside the project directory. */ @Nonnull public File getTemplateDir() { return new File(getRootDir(), TEMPLATE); } @Nonnull @Override public File getRootDirFor(P child) { if (child.equals(template)) { return getTemplateDir(); } // All others are branches return super.getRootDirFor(child); } /** * If copied, also copy the {@link #template}. * <br> * {@inheritDoc} */ @Override public void onCopiedFrom(Item src) { super.onCopiedFrom(src); //noinspection unchecked TemplateDrivenMultiBranchProject<P, B> projectSrc = (TemplateDrivenMultiBranchProject<P, B>) src; /* * onLoad should have been invoked already, so there should be an * empty template. Just update by XML and that's it. */ try { template.updateByXml((Source) new StreamSource(projectSrc.getTemplate().getConfigFile().readRaw())); } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to copy template from " + src.getName() + " into " + getName(), e); } } /** * Sets various implementation-specific fields and forwards wrapped req/rsp objects on to the * {@link #template}'s {@link AbstractProject#doConfigSubmit(StaplerRequest, StaplerResponse)} method. * <br> * {@inheritDoc} */ @Override public void submit(StaplerRequest req, StaplerResponse rsp) throws ServletException, Descriptor.FormException, IOException { super.submit(req, rsp); makeDisabled(req.getParameter("disable") != null); template.doConfigSubmit( new TemplateStaplerRequestWrapper(req), new TemplateStaplerResponseWrapper(req.getStapler(), rsp)); ItemListener.fireOnUpdated(this); // notify the queue as the project might be now tied to different node Jenkins.getActiveInstance().getQueue().scheduleMaintenance(); // this is to reflect the upstream build adjustments done above Jenkins.getActiveInstance().rebuildDependencyGraphAsync(); } /** * Returns the last build. * * @return the build or null */ @SuppressWarnings(UNUSED) @CheckForNull @Exported public Run getLastBuild() { Run retVal = null; for (Job job : getAllJobs()) { retVal = takeLast(retVal, job.getLastBuild()); } return retVal; } /** * Returns the oldest build in the record. * * @return the build or null */ @SuppressWarnings(UNUSED) @CheckForNull @Exported public Run getFirstBuild() { Run retVal = null; for (Job job : getAllJobs()) { Run run = job.getFirstBuild(); if (run != null && (retVal == null || run.getTimestamp().before(retVal.getTimestamp()))) { retVal = run; } } return retVal; } /** * Returns the last successful build, if any. Otherwise null. A successful build would include * either {@link Result#SUCCESS} or {@link Result#UNSTABLE}. * * @return the build or null * @see #getLastStableBuild() */ @SuppressWarnings(UNUSED) @CheckForNull @Exported public Run getLastSuccessfulBuild() { Run retVal = null; for (Job job : getAllJobs()) { retVal = takeLast(retVal, job.getLastSuccessfulBuild()); } return retVal; } /** * Returns the last build that was anything but stable, if any. Otherwise null. * * @return the build or null * @see #getLastSuccessfulBuild */ @SuppressWarnings(UNUSED) @CheckForNull @Exported public Run getLastUnsuccessfulBuild() { Run retVal = null; for (Job job : getAllJobs()) { retVal = takeLast(retVal, job.getLastUnsuccessfulBuild()); } return retVal; } /** * Returns the last unstable build, if any. Otherwise null. * * @return the build or null * @see #getLastSuccessfulBuild */ @SuppressWarnings(UNUSED) @CheckForNull @Exported public Run getLastUnstableBuild() { Run retVal = null; for (Job job : getAllJobs()) { retVal = takeLast(retVal, job.getLastUnstableBuild()); } return retVal; } /** * Returns the last stable build, if any. Otherwise null. * * @return the build or null * @see #getLastSuccessfulBuild */ @SuppressWarnings(UNUSED) @CheckForNull @Exported public Run getLastStableBuild() { Run retVal = null; for (Job job : getAllJobs()) { retVal = takeLast(retVal, job.getLastStableBuild()); } return retVal; } /** * Returns the last failed build, if any. Otherwise null. * * @return the build or null */ @SuppressWarnings(UNUSED) @CheckForNull @Exported public Run getLastFailedBuild() { Run retVal = null; for (Job job : getAllJobs()) { retVal = takeLast(retVal, job.getLastFailedBuild()); } return retVal; } /** * Returns the last completed build, if any. Otherwise null. * * @return the build or null */ @SuppressWarnings(UNUSED) @CheckForNull @Exported public Run getLastCompletedBuild() { Run retVal = null; for (Job job : getAllJobs()) { retVal = takeLast(retVal, job.getLastCompletedBuild()); } return retVal; } @CheckForNull private Run takeLast(Run run1, Run run2) { if (run2 != null && (run1 == null || run2.getTimestamp().after(run1.getTimestamp()))) { return run2; } return run1; } @Override public boolean isBuildable() { return !isDisabled() && super.isBuildable(); } /** * Gets whether this project is disabled. * * @return boolean - true: disabled, false: enabled */ public boolean isDisabled() { return disabled; } /** * Marks the build as disabled. * * @param b true - disable, false - enable * @throws IOException if problem saving */ public void makeDisabled(boolean b) throws IOException { if (disabled == b) { return; } this.disabled = b; Collection<P> projects = getItems(); // Manage the sub-projects if (b) { /* * Populate list only if it is empty. Running this loop when the * parent (and therefore, all sub-projects) are already disabled will * add all branches. Obviously not desirable. */ if (disabledSubProjects.isEmpty()) { for (P project : projects) { if (project.isDisabled()) { disabledSubProjects.add(project.getName()); } } } // Always forcefully disable all sub-projects for (P project : projects) { project.disable(); } } else { // Re-enable only the projects that weren't manually marked disabled for (P project : projects) { if (!disabledSubProjects.contains(project.getName())) { project.enable(); } } // Clear the list so it can be rebuilt when parent is disabled disabledSubProjects.clear(); } save(); ItemListener.fireOnUpdated(this); } /** * Specifies whether this project may be disabled by the user. By default, it can be only if this * is a {@link TopLevelItem}; would be false for matrix configurations, etc. * * @return true if the GUI should allow {@link #doDisable} and the like */ @SuppressWarnings(UNUSED) public boolean supportsMakeDisabled() { return true; } @SuppressWarnings(UNUSED) public void disable() throws IOException { makeDisabled(true); } @SuppressWarnings(UNUSED) public void enable() throws IOException { makeDisabled(false); } @SuppressWarnings(UNUSED) @CLIMethod(name = "disable-job") @RequirePOST public HttpResponse doDisable() throws IOException, ServletException { // NOSONAR checkPermission(CONFIGURE); makeDisabled(true); return new HttpRedirect("."); } @SuppressWarnings(UNUSED) @CLIMethod(name = "enable-job") @RequirePOST public HttpResponse doEnable() throws IOException, ServletException { // NOSONAR checkPermission(CONFIGURE); makeDisabled(false); return new HttpRedirect("."); } /** * Gets whether or not this item is configurable (always true). Used in Jelly. * * @return boolean - true */ @SuppressWarnings(UNUSED) public boolean isConfigurable() { return true; } /** * Get the term used in the UI to represent this kind of {@link AbstractProject}. Must start with a capital letter. */ @Override public String getPronoun() { return AlternativeUiTextProvider.get(PRONOUN, this, hudson.model.Messages.AbstractProject_Pronoun()); } /** * Returns a list of {@link ViewDescriptor}s that we want to use for this project type. Used by newView.jelly. * * @return list of {@link ViewDescriptor}s that we want to use for this project type */ @SuppressWarnings(UNUSED) public static List<ViewDescriptor> getViewDescriptors() { return Collections.singletonList( (ViewDescriptor) Jenkins.getActiveInstance().getDescriptorByType(BranchListView.DescriptorImpl.class)); } /** * Triggered by different listeners to enforce state for multi-branch projects and their sub-projects. * <ul> * <li>Watches for changes to the template project and corrects the SCM and enabled/disabled state if modified.</li> * <li>Looks for rogue template project in the branches directory and removes it if no such sub-project exists.</li> * <li>Re-disables sub-projects if they were enabled when the parent project was disabled.</li> * </ul> * * @param item the item that was just updated */ public static void enforceProjectStateOnUpdated(Item item) { if (item.getParent() instanceof TemplateDrivenMultiBranchProject) { TemplateDrivenMultiBranchProject parent = (TemplateDrivenMultiBranchProject) item.getParent(); AbstractProject template = parent.getTemplate(); if (item.equals(template)) { try { if (!(template.getScm() instanceof NullSCM)) { template.setScm(new NullSCM()); } if (!template.isDisabled()) { template.disable(); } } catch (IOException e) { LOGGER.log(Level.WARNING, "Unable to correct template configuration.", e); } } // Don't allow sub-projects to be enabled if parent is disabled AbstractProject project = (AbstractProject) item; if (parent.isDisabled() && !project.isDisabled()) { try { project.disable(); } catch (IOException e) { LOGGER.log(Level.WARNING, "Unable to keep sub-project disabled.", e); } } } } /** * Additional listener for normal changes to Items in the UI, used to enforce state for * multi-branch projects and their sub-projects. */ @SuppressWarnings(UNUSED) @Extension public static final class BranchProjectItemListener extends ItemListener { @Override public void onUpdated(Item item) { enforceProjectStateOnUpdated(item); } } /** * Additional listener for changes to Items via config.xml POST, used to enforce state for * multi-branch projects and their sub-projects. */ @SuppressWarnings(UNUSED) @Extension public static final class BranchProjectSaveListener extends SaveableListener { @Override public void onChange(Saveable o, XmlFile file) { if (o instanceof Item) { enforceProjectStateOnUpdated((Item) o); } } } }