/* * The MIT License * * Copyright (c) 2004-2010, Sun Microsystems, Inc., Kohsuke Kawaguchi, * Yahoo! Inc., Erik Ramfelt, Tom Huybrechts * * 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; import com.google.common.collect.ImmutableSet; import hudson.PluginManager.PluginInstanceStore; import hudson.model.AdministrativeMonitor; import hudson.model.Api; import hudson.model.ModelObject; import jenkins.YesNoMaybe; import jenkins.model.Jenkins; import hudson.model.UpdateCenter; import hudson.model.UpdateSite; import hudson.util.VersionNumber; import org.jvnet.localizer.ResourceBundleHolder; import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.HttpResponses; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.export.ExportedBean; import org.kohsuke.stapler.interceptor.RequirePOST; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.LogFactory; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import java.io.Closeable; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.jar.JarFile; import java.util.jar.Manifest; import java.util.logging.Level; import java.util.logging.Logger; import static java.util.logging.Level.WARNING; import static org.apache.commons.io.FilenameUtils.getBaseName; /** * Represents a Jenkins plug-in and associated control information * for Jenkins to control {@link Plugin}. * * <p> * A plug-in is packaged into a jar file whose extension is <tt>".jpi"</tt> (or <tt>".hpi"</tt> for backward compatibility), * A plugin needs to have a special manifest entry to identify what it is. * * <p> * At the runtime, a plugin has two distinct state axis. * <ol> * <li>Enabled/Disabled. If enabled, Jenkins is going to use it * next time Jenkins runs. Otherwise the next run will ignore it. * <li>Activated/Deactivated. If activated, that means Jenkins is using * the plugin in this session. Otherwise it's not. * </ol> * <p> * For example, an activated but disabled plugin is still running but the next * time it won't. * * @author Kohsuke Kawaguchi */ @ExportedBean public class PluginWrapper implements Comparable<PluginWrapper>, ModelObject { /** * A plugin won't be loaded unless his declared dependencies are present and match the required minimal version. * This can be set to false to disable the version check (legacy behaviour) */ private static final boolean ENABLE_PLUGIN_DEPENDENCIES_VERSION_CHECK = Boolean.parseBoolean(System.getProperty(PluginWrapper.class.getName()+"." + "dependenciesVersionCheck.enabled", "true")); /** * {@link PluginManager} to which this belongs to. */ public final PluginManager parent; /** * Plugin manifest. * Contains description of the plugin. */ private final Manifest manifest; /** * {@link ClassLoader} for loading classes from this plugin. * Null if disabled. */ public final ClassLoader classLoader; /** * Base URL for loading static resources from this plugin. * Null if disabled. The static resources are mapped under * <tt>CONTEXTPATH/plugin/SHORTNAME/</tt>. */ public final URL baseResourceURL; /** * Used to control enable/disable setting of the plugin. * If this file exists, plugin will be disabled. */ private final File disableFile; /** * A .jpi file, an exploded plugin directory, or a .jpl file. */ private final File archive; /** * Short name of the plugin. The artifact Id of the plugin. * This is also used in the URL within Jenkins, so it needs * to remain stable even when the *.jpi file name is changed * (like Maven does.) */ private final String shortName; /** * True if this plugin is activated for this session. * The snapshot of <tt>disableFile.exists()</tt> as of the start up. */ private final boolean active; private boolean hasCycleDependency = false; private final List<Dependency> dependencies; private final List<Dependency> optionalDependencies; public List<String> getDependencyErrors() { return Collections.unmodifiableList(dependencyErrors); } private final transient List<String> dependencyErrors = new ArrayList<>(); /** * Is this plugin bundled in jenkins.war? */ /*package*/ boolean isBundled; /** * List of plugins that depend on this plugin. */ private Set<String> dependants = Collections.emptySet(); /** * The core can depend on a plugin if it is bundled. Sometimes it's the only thing that * depends on the plugin e.g. UI support library bundle plugin. */ private static Set<String> CORE_ONLY_DEPENDANT = ImmutableSet.copyOf(Arrays.asList("jenkins-core")); /** * Set the list of components that depend on this plugin. * @param dependants The list of components that depend on this plugin. */ public void setDependants(@Nonnull Set<String> dependants) { this.dependants = dependants; } /** * Get the list of components that depend on this plugin. * @return The list of components that depend on this plugin. */ public @Nonnull Set<String> getDependants() { if (isBundled && dependants.isEmpty()) { return CORE_ONLY_DEPENDANT; } else { return dependants; } } /** * Does this plugin have anything that depends on it. * @return {@code true} if something (Jenkins core, or another plugin) depends on this * plugin, otherwise {@code false}. */ public boolean hasDependants() { return (isBundled || !dependants.isEmpty()); } /** * Does this plugin depend on any other plugins. * @return {@code true} if this plugin depends on other plugins, otherwise {@code false}. */ public boolean hasDependencies() { return (dependencies != null && !dependencies.isEmpty()); } @ExportedBean public static final class Dependency { @Exported public final String shortName; @Exported public final String version; @Exported public final boolean optional; public Dependency(String s) { int idx = s.indexOf(':'); if(idx==-1) throw new IllegalArgumentException("Illegal dependency specifier "+s); this.shortName = s.substring(0,idx); String version = s.substring(idx+1); boolean isOptional = false; String[] osgiProperties = version.split("[;]"); for (int i = 1; i < osgiProperties.length; i++) { String osgiProperty = osgiProperties[i].trim(); if (osgiProperty.equalsIgnoreCase("resolution:=optional")) { isOptional = true; } } this.optional = isOptional; if (isOptional) { this.version = osgiProperties[0]; } else { this.version = version; } } @Override public String toString() { return shortName + " (" + version + ")" + (optional ? " optional" : ""); } } /** * @param archive * A .jpi archive file jar file, or a .jpl linked plugin. * @param manifest * The manifest for the plugin * @param baseResourceURL * A URL pointing to the resources for this plugin * @param classLoader * a classloader that loads classes from this plugin and its dependencies * @param disableFile * if this file exists on startup, the plugin will not be activated * @param dependencies a list of mandatory dependencies * @param optionalDependencies a list of optional dependencies */ public PluginWrapper(PluginManager parent, File archive, Manifest manifest, URL baseResourceURL, ClassLoader classLoader, File disableFile, List<Dependency> dependencies, List<Dependency> optionalDependencies) { this.parent = parent; this.manifest = manifest; this.shortName = computeShortName(manifest, archive.getName()); this.baseResourceURL = baseResourceURL; this.classLoader = classLoader; this.disableFile = disableFile; this.active = !disableFile.exists(); this.dependencies = dependencies; this.optionalDependencies = optionalDependencies; this.archive = archive; } public String getDisplayName() { return StringUtils.removeStart(getLongName(), "Jenkins "); } public Api getApi() { Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER); return new Api(this); } /** * Returns the URL of the index page jelly script. */ public URL getIndexPage() { // In the current impl dependencies are checked first, so the plugin itself // will add the last entry in the getResources result. URL idx = null; try { Enumeration<URL> en = classLoader.getResources("index.jelly"); while (en.hasMoreElements()) idx = en.nextElement(); } catch (IOException ignore) { } // In case plugin has dependencies but is missing its own index.jelly, // check that result has this plugin's artifactId in it: return idx != null && idx.toString().contains(shortName) ? idx : null; } static String computeShortName(Manifest manifest, String fileName) { // use the name captured in the manifest, as often plugins // depend on the specific short name in its URLs. String n = manifest.getMainAttributes().getValue("Short-Name"); if(n!=null) return n; // maven seems to put this automatically, so good fallback to check. n = manifest.getMainAttributes().getValue("Extension-Name"); if(n!=null) return n; // otherwise infer from the file name, since older plugins don't have // this entry. return getBaseName(fileName); } @Exported public List<Dependency> getDependencies() { return dependencies; } public List<Dependency> getOptionalDependencies() { return optionalDependencies; } /** * Returns the short name suitable for URL. */ @Exported public String getShortName() { return shortName; } /** * Gets the instance of {@link Plugin} contributed by this plugin. */ public @CheckForNull Plugin getPlugin() { PluginInstanceStore pis = Jenkins.lookup(PluginInstanceStore.class); return pis != null ? pis.store.get(this) : null; } /** * Gets the URL that shows more information about this plugin. * @return * null if this information is unavailable. * @since 1.283 */ @Exported public String getUrl() { // first look for the manifest entry. This is new in maven-hpi-plugin 1.30 String url = manifest.getMainAttributes().getValue("Url"); if(url!=null) return url; // fallback to update center metadata UpdateSite.Plugin ui = getInfo(); if(ui!=null) return ui.wiki; return null; } @Override public String toString() { return "Plugin:" + getShortName(); } /** * Returns a one-line descriptive name of this plugin. */ @Exported public String getLongName() { String name = manifest.getMainAttributes().getValue("Long-Name"); if(name!=null) return name; return shortName; } /** * Does this plugin supports dynamic loading? */ @Exported public YesNoMaybe supportsDynamicLoad() { String v = manifest.getMainAttributes().getValue("Support-Dynamic-Loading"); if (v==null) return YesNoMaybe.MAYBE; return Boolean.parseBoolean(v) ? YesNoMaybe.YES : YesNoMaybe.NO; } /** * Returns the version number of this plugin */ @Exported public String getVersion() { return getVersionOf(manifest); } private String getVersionOf(Manifest manifest) { String v = manifest.getMainAttributes().getValue("Plugin-Version"); if(v!=null) return v; // plugins generated before maven-hpi-plugin 1.3 should still have this attribute v = manifest.getMainAttributes().getValue("Implementation-Version"); if(v!=null) return v; return "???"; } /** * Returns the required Jenkins core version of this plugin. * @return the required Jenkins core version of this plugin. * @since XXX */ @Exported public @CheckForNull String getRequiredCoreVersion() { String v = manifest.getMainAttributes().getValue("Jenkins-Version"); if (v!= null) return v; v = manifest.getMainAttributes().getValue("Hudson-Version"); if (v!= null) return v; return null; } /** * Returns the version number of this plugin */ public VersionNumber getVersionNumber() { return new VersionNumber(getVersion()); } /** * Returns true if the version of this plugin is older than the given version. */ public boolean isOlderThan(VersionNumber v) { try { return getVersionNumber().compareTo(v) < 0; } catch (IllegalArgumentException e) { // if we can't figure out our current version, it probably means it's very old, // since the version information is missing only from the very old plugins return true; } } /** * Terminates the plugin. */ public void stop() { Plugin plugin = getPlugin(); if (plugin != null) { try { LOGGER.log(Level.FINE, "Stopping {0}", shortName); plugin.stop(); } catch (Throwable t) { LOGGER.log(WARNING, "Failed to shut down " + shortName, t); } } else { LOGGER.log(Level.FINE, "Could not find Plugin instance to stop for {0}", shortName); } // Work around a bug in commons-logging. // See http://www.szegedi.org/articles/memleak.html LogFactory.release(classLoader); } public void releaseClassLoader() { if (classLoader instanceof Closeable) try { ((Closeable) classLoader).close(); } catch (IOException e) { LOGGER.log(WARNING, "Failed to shut down classloader",e); } } /** * Enables this plugin next time Jenkins runs. */ public void enable() throws IOException { if (!disableFile.exists()) { LOGGER.log(Level.FINEST, "Plugin {0} has been already enabled. Skipping the enable() operation", getShortName()); return; } if(!disableFile.delete()) throw new IOException("Failed to delete "+disableFile); } /** * Disables this plugin next time Jenkins runs. */ public void disable() throws IOException { // creates an empty file OutputStream os = new FileOutputStream(disableFile); os.close(); } /** * Returns true if this plugin is enabled for this session. */ @Exported public boolean isActive() { return active && !hasCycleDependency(); } public boolean hasCycleDependency(){ return hasCycleDependency; } public void setHasCycleDependency(boolean hasCycle){ hasCycleDependency = hasCycle; } @Exported public boolean isBundled() { return isBundled; } /** * If true, the plugin is going to be activated next time * Jenkins runs. */ @Exported public boolean isEnabled() { return !disableFile.exists(); } public Manifest getManifest() { return manifest; } public void setPlugin(Plugin plugin) { Jenkins.lookup(PluginInstanceStore.class).store.put(this,plugin); plugin.wrapper = this; } public String getPluginClass() { return manifest.getMainAttributes().getValue("Plugin-Class"); } public boolean hasLicensesXml() { try { new URL(baseResourceURL,"WEB-INF/licenses.xml").openStream().close(); return true; } catch (IOException e) { return false; } } /** * Makes sure that all the dependencies exist, and then accept optional dependencies * as real dependencies. * * @throws IOException * thrown if one or several mandatory dependencies doesn't exists. */ /*package*/ void resolvePluginDependencies() throws IOException { if (ENABLE_PLUGIN_DEPENDENCIES_VERSION_CHECK) { String requiredCoreVersion = getRequiredCoreVersion(); if (requiredCoreVersion == null) { LOGGER.warning(shortName + " doesn't declare required core version."); } else { VersionNumber actualVersion = Jenkins.getVersion(); if (actualVersion.isOlderThan(new VersionNumber(requiredCoreVersion))) { dependencyErrors.add(Messages.PluginWrapper_obsoleteCore(Jenkins.getVersion().toString(), requiredCoreVersion)); } } } // make sure dependencies exist for (Dependency d : dependencies) { PluginWrapper dependency = parent.getPlugin(d.shortName); if (dependency == null) { PluginWrapper failedDependency = NOTICE.getPlugin(d.shortName); if (failedDependency != null) { dependencyErrors.add(Messages.PluginWrapper_failed_to_load_dependency(failedDependency.getLongName(), d.version)); break; } else { dependencyErrors.add(Messages.PluginWrapper_missing(d.shortName, d.version)); } } else { if (dependency.isActive()) { if (isDependencyObsolete(d, dependency)) { dependencyErrors.add(Messages.PluginWrapper_obsolete(dependency.getLongName(), dependency.getVersion(), d.version)); } } else { if (isDependencyObsolete(d, dependency)) { dependencyErrors.add(Messages.PluginWrapper_disabledAndObsolete(dependency.getLongName(), dependency.getVersion(), d.version)); } else { dependencyErrors.add(Messages.PluginWrapper_disabled(dependency.getLongName())); } } } } // add the optional dependencies that exists for (Dependency d : optionalDependencies) { PluginWrapper dependency = parent.getPlugin(d.shortName); if (dependency != null && dependency.isActive()) { if (isDependencyObsolete(d, dependency)) { dependencyErrors.add(Messages.PluginWrapper_obsolete(dependency.getLongName(), dependency.getVersion(), d.version)); } else { dependencies.add(d); } } } if (!dependencyErrors.isEmpty()) { NOTICE.addPlugin(this); StringBuilder messageBuilder = new StringBuilder(); messageBuilder.append(Messages.PluginWrapper_failed_to_load_plugin(getLongName(), getVersion())).append(System.lineSeparator()); for (Iterator<String> iterator = dependencyErrors.iterator(); iterator.hasNext(); ) { String dependencyError = iterator.next(); messageBuilder.append(" - ").append(dependencyError); if (iterator.hasNext()) { messageBuilder.append(System.lineSeparator()); } } throw new IOException(messageBuilder.toString()); } } private boolean isDependencyObsolete(Dependency d, PluginWrapper dependency) { return ENABLE_PLUGIN_DEPENDENCIES_VERSION_CHECK && dependency.getVersionNumber().isOlderThan(new VersionNumber(d.version)); } /** * If the plugin has {@link #getUpdateInfo() an update}, * returns the {@link hudson.model.UpdateSite.Plugin} object. * * @return * This method may return null — for example, * the user may have installed a plugin locally developed. */ public UpdateSite.Plugin getUpdateInfo() { UpdateCenter uc = Jenkins.getInstance().getUpdateCenter(); UpdateSite.Plugin p = uc.getPlugin(getShortName()); if(p!=null && p.isNewerThan(getVersion())) return p; return null; } /** * returns the {@link hudson.model.UpdateSite.Plugin} object, or null. */ public UpdateSite.Plugin getInfo() { UpdateCenter uc = Jenkins.getInstance().getUpdateCenter(); return uc.getPlugin(getShortName()); } /** * Returns true if this plugin has update in the update center. * * <p> * This method is conservative in the sense that if the version number is incomprehensible, * it always returns false. */ @Exported public boolean hasUpdate() { return getUpdateInfo()!=null; } @Exported @Deprecated // See https://groups.google.com/d/msg/jenkinsci-dev/kRobm-cxFw8/6V66uhibAwAJ public boolean isPinned() { return false; } /** * Returns true if this plugin is deleted. * * The plugin continues to function in this session, but in the next session it'll disappear. */ @Exported public boolean isDeleted() { return !archive.exists(); } /** * Sort by short name. */ public int compareTo(PluginWrapper pw) { return shortName.compareToIgnoreCase(pw.shortName); } /** * returns true if backup of previous version of plugin exists */ @Exported public boolean isDowngradable() { return getBackupFile().exists(); } /** * Where is the backup file? */ public File getBackupFile() { return new File(Jenkins.getInstance().getRootDir(),"plugins/"+getShortName() + ".bak"); } /** * returns the version of the backed up plugin, * or null if there's no back up. */ @Exported public String getBackupVersion() { File backup = getBackupFile(); if (backup.exists()) { try { JarFile backupPlugin = new JarFile(backup); try { return backupPlugin.getManifest().getMainAttributes().getValue("Plugin-Version"); } finally { backupPlugin.close(); } } catch (IOException e) { LOGGER.log(WARNING, "Failed to get backup version from " + backup, e); return null; } } else { return null; } } /** * Checks if this plugin is pinned and that's forcing us to use an older version than the bundled one. */ @Deprecated // See https://groups.google.com/d/msg/jenkinsci-dev/kRobm-cxFw8/6V66uhibAwAJ public boolean isPinningForcingOldVersion() { return false; } @Extension public final static PluginWrapperAdministrativeMonitor NOTICE = new PluginWrapperAdministrativeMonitor(); /** * Administrative Monitor for failed plugins */ public static final class PluginWrapperAdministrativeMonitor extends AdministrativeMonitor { private final Map<String, PluginWrapper> plugins = new HashMap<>(); void addPlugin(PluginWrapper plugin) { plugins.put(plugin.shortName, plugin); } public boolean isActivated() { return !plugins.isEmpty(); } public Collection<PluginWrapper> getPlugins() { return plugins.values(); } public PluginWrapper getPlugin(String shortName) { return plugins.get(shortName); } /** * Depending on whether the user said "dismiss" or "correct", send him to the right place. */ public void doAct(StaplerRequest req, StaplerResponse rsp) throws IOException { if(req.hasParameter("correct")) { rsp.sendRedirect(req.getContextPath()+"/pluginManager"); } } public static PluginWrapperAdministrativeMonitor get() { return AdministrativeMonitor.all().get(PluginWrapperAdministrativeMonitor.class); } } // // // Action methods // // @RequirePOST public HttpResponse doMakeEnabled() throws IOException { Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER); enable(); return HttpResponses.ok(); } @RequirePOST public HttpResponse doMakeDisabled() throws IOException { Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER); disable(); return HttpResponses.ok(); } @RequirePOST @Deprecated public HttpResponse doPin() throws IOException { Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER); // See https://groups.google.com/d/msg/jenkinsci-dev/kRobm-cxFw8/6V66uhibAwAJ LOGGER.log(WARNING, "Call to pin plugin has been ignored. Plugin name: " + shortName); return HttpResponses.ok(); } @RequirePOST @Deprecated public HttpResponse doUnpin() throws IOException { Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER); // See https://groups.google.com/d/msg/jenkinsci-dev/kRobm-cxFw8/6V66uhibAwAJ LOGGER.log(WARNING, "Call to unpin plugin has been ignored. Plugin name: " + shortName); return HttpResponses.ok(); } @RequirePOST public HttpResponse doDoUninstall() throws IOException { Jenkins jenkins = Jenkins.getActiveInstance(); jenkins.checkPermission(Jenkins.ADMINISTER); archive.delete(); // Redo who depends on who. jenkins.getPluginManager().resolveDependantPlugins(); return HttpResponses.redirectViaContextPath("/pluginManager/installed"); // send back to plugin manager } private static final Logger LOGGER = Logger.getLogger(PluginWrapper.class.getName()); /** * Name of the plugin manifest file (to help find where we parse them.) */ public static final String MANIFEST_FILENAME = "META-INF/MANIFEST.MF"; }