package hudson.plugins.promoted_builds; import java.io.File; import java.io.FileFilter; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.ListIterator; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.lang.StringUtils; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.Ancestor; import org.kohsuke.stapler.StaplerRequest; import hudson.Extension; import hudson.Util; import hudson.model.AbstractBuild; import hudson.model.AbstractProject; import hudson.model.Action; import hudson.model.BuildListener; import hudson.model.Descriptor; import hudson.model.Failure; import hudson.model.Hudson; import hudson.model.Item; import hudson.model.ItemGroup; import hudson.model.ItemGroupMixIn; import hudson.model.Items; import hudson.model.Job; import hudson.model.JobProperty; import hudson.model.JobPropertyDescriptor; import hudson.model.listeners.ItemListener; import hudson.remoting.Callable; import hudson.util.IOUtils; import javax.annotation.CheckForNull; import jenkins.model.Jenkins; import net.sf.json.JSONArray; import net.sf.json.JSONObject; /** * Promotion processes defined for a project. * * <p> * TODO: a possible performance problem as every time the owner job is reconfigured, * all the promotion processes get reloaded from the disk. * </p> * @author Kohsuke Kawaguchi */ public final class JobPropertyImpl extends JobProperty<AbstractProject<?,?>> implements ItemGroup<PromotionProcess> { /** * These are loaded from the disk in a different way. */ private transient /*final*/ List<PromotionProcess> processes = new ArrayList<PromotionProcess>(); /** * Subset of {@link #processes} that only contains {@link #activeProcessNames processes that are active}. * This is really just a cache and not an independent variable. */ private transient /*final*/ List<PromotionProcess> activeProcesses; /** * These {@link PromotionProcess}es are active. */ private final Set<String> activeProcessNames = new HashSet<String>(); /** * Programmatic construction. * @param owner owner job */ public JobPropertyImpl(AbstractProject<?,?> owner) throws Descriptor.FormException, IOException { this.owner = owner; init(); } /** * Programmatic construction. * @param other Property to be copied * @param owner owner job */ public JobPropertyImpl(JobPropertyImpl other, AbstractProject<?,?> owner) throws Descriptor.FormException, IOException { this.owner = owner; this.activeProcessNames.addAll(other.activeProcessNames); loadAllProcesses(other.getRootDir()); } /** * Programmatic construction. */ @Restricted(NoExternalUse.class) public JobPropertyImpl(Set<String> activeProcessNames) { this.activeProcessNames.addAll(activeProcessNames); } private JobPropertyImpl(StaplerRequest req, JSONObject json) throws Descriptor.FormException, IOException { // a hack to get the owning AbstractProject. // this is needed here so that we can load items List<Ancestor> ancs = req.getAncestors(); final Object ancestor = ancs.get(ancs.size()-1).getObject(); if (ancestor instanceof AbstractProject) { owner = (AbstractProject)ancestor; } else if (ancestor == null) { throw new Descriptor.FormException("Cannot retrieve the ancestor item in the request", "owner"); } else { throw new Descriptor.FormException("Cannot create Promoted Builds Job Property for " + ancestor.getClass() + ". Currently the plugin supports instances of AbstractProject only." + ". Other job types are not supported, submit a bug to the plugin, which provides the job type" + ". If you use Multi-Branch Project plugin, see https://issues.jenkins-ci.org/browse/JENKINS-32237", "owner"); } // newer version of Hudson put "promotions". This code makes it work with or without them. if(json.has("promotions")) json = json.getJSONObject("promotions"); for( Object o : JSONArray.fromObject(json.get("activeItems")) ) { JSONObject c = (JSONObject)o; String name = c.getString("name"); try { Hudson.checkGoodName(name); } catch (Failure f) { throw new Descriptor.FormException(f.getMessage(), name); } activeProcessNames.add(name); PromotionProcess p; try { p = (PromotionProcess) Items.load(this, getRootDirFor(name)); } catch (IOException e) { // failed to load p = new PromotionProcess(this,name); } // apply configuration p.configure(req,c); safeAddToProcessesList(p); } init(); } private void loadAllProcesses(File rootDir) throws IOException { File[] subdirs = rootDir.listFiles(new FileFilter() { public boolean accept(File child) { return child.isDirectory(); } }); loadProcesses(subdirs); } private void init() throws IOException { // load inactive processes File[] subdirs = getRootDir().listFiles(new FileFilter() { public boolean accept(File child) { return child.isDirectory() && !isActiveProcessNameIgnoreCase(child.getName()); } }); loadProcesses(subdirs); } private void loadProcesses(File[] subdirs) throws IOException { if(subdirs!=null) { for (File subdir : subdirs) { try { PromotionProcess p = (PromotionProcess) Items.load(this, subdir); safeAddToProcessesList(p); } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to load promotion process in "+subdir,e); } } } buildActiveProcess(); } /** * Adds a new promotion process of the given name. * @param name Name of the process to be created * @return Created process * @throws IOException Execution error */ public synchronized PromotionProcess addProcess(String name) throws IOException { PromotionProcess p = new PromotionProcess(this, name); activeProcessNames.add(name); safeAddToProcessesList(p); buildActiveProcess(); p.onCreatedFromScratch(); return p; } private synchronized void safeAddToProcessesList(PromotionProcess p) { int index = 0; boolean found = false; for (ListIterator<PromotionProcess> i = processes.listIterator(); i.hasNext();) { PromotionProcess process = i.next(); if (p.getName().equalsIgnoreCase(process.getName())) { found = true; try { i.set(p); break; } catch (UnsupportedOperationException e) { // shouldn't end up here but Java Runtime Spec allows for this case // we don't care about ConcurrentModificationException because we are done // with the iterator once we find the first element. processes.set(index, p); break; } } index++; } if (!found) { processes.add(p); } } @Override protected void setOwner(AbstractProject<?,?> owner) { super.setOwner(owner); // readResolve is too early because we don't have our parent set yet, // so use this as the initialization opportunity. // CopyListener is also using setOwner to re-init after copying config from another job. synchronized (this) { processes = new ArrayList<PromotionProcess>(ItemGroupMixIn.<String, PromotionProcess>loadChildren( this, getRootDir(), ItemGroupMixIn.KEYED_BY_NAME).values()); try { buildActiveProcess(); } catch (IOException e) { throw new Error(e); } } } /** * Builds {@link #activeProcesses}. * @throws IOException Execution error */ private void buildActiveProcess() throws IOException { activeProcesses = new ArrayList<PromotionProcess>(); for (PromotionProcess p : processes) { boolean active = isActiveProcessNameIgnoreCase(p.getName()); p.makeDisabled(!active); if(active) activeProcesses.add(p); // ensure that the name casing matches what's given in the activeProcessName // this is because in case insensitive file system, we may end up resolving // to a directory name that differs only in their case. String processName = p.getName(); String activeProcessName = getActiveProcessName(processName); if (!activeProcessName.equals(processName)){ p.renameTo(activeProcessName); } } } /** * Return the string in the case as specified in {@link #activeProcessNames}. */ private synchronized String getActiveProcessName(String s) { for (String n : activeProcessNames) { if (n.equalsIgnoreCase(s)) return n; } return s; // huh? } private synchronized boolean isActiveProcessNameIgnoreCase(String s) { for (String n : activeProcessNames) if (n.equalsIgnoreCase(s)) return true; return false; } /** * Gets the list of promotion processes defined for this project, * including ones that are no longer actively used and only * for archival purpose. * * @return * non-null and non-empty. Read-only. */ public synchronized List<PromotionProcess> getItems() { return processes; } /** * Gets the list of active promotion processes. */ public List<PromotionProcess> getActiveItems() { return activeProcesses; } /** @see ItemGroupMixIn#createProjectFromXML */ public PromotionProcess createProcessFromXml(final String name, InputStream xml) throws IOException { owner.checkPermission(Item.CONFIGURE); // CREATE is ItemGroup-scoped and owner is not an ItemGroup Jenkins.getInstance().getProjectNamingStrategy().checkName(name); if (getItem(name) != null) { throw new IllegalArgumentException(owner.getDisplayName() + " already contains an item '" + name + "'"); } File configXml = Items.getConfigFile(getRootDirFor(name)).getFile(); File dir = configXml.getParentFile(); if (!dir.mkdirs()) { throw new IOException("Cannot create directories for "+dir); } try { IOUtils.copy(xml, configXml); PromotionProcess result = Items.whileUpdatingByXml(new Callable<PromotionProcess,IOException>() { @Override public PromotionProcess call() throws IOException { setOwner(owner); return getItem(name); } }); if (result == null) { throw new IOException("failed to load from " + configXml); } ItemListener.fireOnCreated(result); return result; } catch (IOException e) { Util.deleteRecursive(dir); throw e; } } /** * Gets {@link AbstractProject} that contains us. * @return Owner project */ public AbstractProject<?,?> getOwner() { return owner; } /** * Finds a {@link PromotionProcess} by name. * @param name Name of the process * @return {@link PromotionProcess} if it can be found. */ @CheckForNull public synchronized PromotionProcess getItem(String name) { if (processes == null) { return null; } for (PromotionProcess c : processes) { if( StringUtils.equals( c.getName(),name)) return c; } return null; } public File getRootDir() { return new File(getOwner().getRootDir(),"promotions"); } public void save() throws IOException { // there's nothing to save, actually } public void onDeleted(PromotionProcess process) { setOwner(owner); ItemListener.fireOnDeleted(process); } public void onRenamed(PromotionProcess item, String oldName, String newName) throws IOException { setOwner(owner); } public String getUrl() { return getOwner().getUrl()+"promotion/"; } public String getFullName() { return getOwner().getFullName()+"/promotion"; } public String getFullDisplayName() { return getOwner().getFullDisplayName()+" \u00BB promotion"; } public String getUrlChildPrefix() { return ""; } public File getRootDirFor(PromotionProcess child) { return getRootDirFor(child.getName()); } File getRootDirFor(String name) { return new File(getRootDir(), name); } public String getDisplayName() { return "promotion"; } @Override public boolean prebuild(AbstractBuild<?,?> build, BuildListener listener) { build.addAction(new PromotedBuildAction(build)); return true; } @Deprecated public Action getJobAction(AbstractProject<?,?> job) { return new PromotedProjectAction(job,this); } @Extension public static final class DescriptorImpl extends JobPropertyDescriptor { public DescriptorImpl() { super(); } public DescriptorImpl(Class<? extends JobProperty<?>> clazz) { super(clazz); } public String getDisplayName() { return "Promote Builds When..."; } @Override public boolean isApplicable(Class<? extends Job> jobType) { return AbstractProject.class.isAssignableFrom(jobType); } @Override public JobPropertyImpl newInstance(StaplerRequest req, JSONObject json) throws Descriptor.FormException { try { if(json.has("promotions")) return new JobPropertyImpl(req, json); return null; } catch (IOException e) { throw new FormException("Failed to create",e,null); // TODO:hmm } } } private static final Logger LOGGER = Logger.getLogger(JobPropertyImpl.class.getName()); }