/** Validate whether the jar file has a valid manifest, throw exception if invalid */ static void validateJarManifest(final Attributes mainAttributes) throws InvalidManifestException { final String value1 = mainAttributes.getValue(RUNDECK_PLUGIN_ARCHIVE); final String plugvers = mainAttributes.getValue(RUNDECK_PLUGIN_VERSION); final String plugclassnames = mainAttributes.getValue(RUNDECK_PLUGIN_CLASSNAMES); if (null == value1) { throw new InvalidManifestException( "Jar plugin manifest attribute missing: " + RUNDECK_PLUGIN_ARCHIVE); } else if (!"true".equals(value1)) { throw new InvalidManifestException(RUNDECK_PLUGIN_ARCHIVE + " was not 'true': " + value1); } if (null == plugvers) { throw new InvalidManifestException( "Jar plugin manifest attribute missing: " + RUNDECK_PLUGIN_VERSION); } final VersionCompare pluginVersion = VersionCompare.forString(plugvers); if (!pluginVersion.atLeast(LOWEST_JAR_PLUGIN_VERSION)) { throw new InvalidManifestException( "Unsupported plugin version: " + RUNDECK_PLUGIN_VERSION + ": " + plugvers); } if (null == plugclassnames) { throw new InvalidManifestException( "Jar plugin manifest attribute missing: " + RUNDECK_PLUGIN_CLASSNAMES); } }
/** * JarPluginProviderLoader can load jar plugin files as provider instances. * * <p>Calls to load a provider instance should be synchronized as this class will perform file copy * operations. * * @author Greg Schueler <a href="mailto:[email protected]">[email protected]</a> */ class JarPluginProviderLoader implements ProviderLoader, FileCache.Expireable, PluginResourceLoader, PluginMetadata { public static final String RESOURCES_DIR_DEFAULT = "resources"; private static Logger log = Logger.getLogger(JarPluginProviderLoader.class.getName()); public static final String RUNDECK_PLUGIN_ARCHIVE = "Rundeck-Plugin-Archive"; public static final String RUNDECK_PLUGIN_CLASSNAMES = "Rundeck-Plugin-Classnames"; public static final String RUNDECK_PLUGIN_RESOURCES = "Rundeck-Plugin-Resources"; public static final String RUNDECK_PLUGIN_RESOURCES_DIR = "Rundeck-Plugin-Resources-Dir"; public static final String RUNDECK_PLUGIN_LIBS = "Rundeck-Plugin-Libs"; public static final String JAR_PLUGIN_VERSION = "1.1"; public static final String JAR_PLUGIN_VERSION_1_2 = "1.2"; public static final VersionCompare SUPPORTS_RESOURCES_PLUGIN_VERSION = VersionCompare.forString(JAR_PLUGIN_VERSION_1_2); public static final VersionCompare LOWEST_JAR_PLUGIN_VERSION = VersionCompare.forString(JAR_PLUGIN_VERSION); public static final String RUNDECK_PLUGIN_VERSION = "Rundeck-Plugin-Version"; public static final String RUNDECK_PLUGIN_FILE_VERSION = "Rundeck-Plugin-File-Version"; public static final String RUNDECK_PLUGIN_AUTHOR = "Rundeck-Plugin-Author"; public static final String RUNDECK_PLUGIN_URL = "Rundeck-Plugin-URL"; public static final String RUNDECK_PLUGIN_DATE = "Rundeck-Plugin-Date"; public static final String RUNDECK_PLUGIN_LIBS_LOAD_FIRST = "Rundeck-Plugin-Libs-Load-First"; public static final String CACHED_JAR_TIMESTAMP_FORMAT = "yyyyMMddHHmmssSSS"; private final File pluginJar; private final File pluginJarCacheDirectory; private final File cachedir; private final boolean loadLibsFirst; private final DateFormat cachedJarTimestampFormatter = new SimpleDateFormat(CACHED_JAR_TIMESTAMP_FORMAT); @SuppressWarnings("rawtypes") private Map<ProviderIdent, Class> pluginProviderDefs = new HashMap<ProviderIdent, Class>(); public JarPluginProviderLoader( final File pluginJar, final File pluginJarCacheDirectory, final File cachedir) { this(pluginJar, pluginJarCacheDirectory, cachedir, true); } public JarPluginProviderLoader( final File pluginJar, final File pluginJarCacheDirectory, final File cachedir, final boolean loadLibsFirst) { if (null == pluginJar) { throw new NullPointerException("Expected non-null plugin jar argument."); } if (!pluginJar.exists()) { throw new IllegalArgumentException("File does not exist: " + pluginJar); } if (!pluginJar.isFile()) { throw new IllegalArgumentException("Not a file: " + pluginJar); } this.pluginJar = pluginJar; this.pluginJarCacheDirectory = pluginJarCacheDirectory; this.cachedir = cachedir; this.loadLibsFirst = loadLibsFirst; } private boolean supportsResources(final String pluginVersion) { return VersionCompare.forString(pluginVersion).atLeast(SUPPORTS_RESOURCES_PLUGIN_VERSION); } @Override public List<String> listResources() throws PluginException, IOException { if (supportsResources(getPluginVersion())) { return getCachedJar().resourcesLoader.listResources(); } return null; } @Override public InputStream openResourceStreamFor(final String path) throws PluginException, IOException { if (supportsResources(getPluginVersion())) { return getCachedJar().resourcesLoader.openResourceStreamFor(path); } return null; } /** Load provider instance for the service */ @SuppressWarnings("unchecked") public synchronized <T> T load(final PluggableService<T> service, final String providerName) throws ProviderLoaderException { final ProviderIdent ident = new ProviderIdent(service.getName(), providerName); debug("loadInstance for " + ident + ": " + pluginJar); if (null == pluginProviderDefs.get(ident)) { final String[] strings = getClassnames(); for (final String classname : strings) { final Class<?> cls; try { cls = loadClass(classname); if (matchesProviderDeclaration(ident, cls)) { pluginProviderDefs.put(ident, cls); } } catch (PluginException e) { log.error( "Failed to load class from " + pluginJar + ": classname: " + classname + ": " + e.getMessage()); } } } final Class<T> cls = pluginProviderDefs.get(ident); if (null != cls) { try { return createProviderForClass(service, cls); } catch (PluginException e) { throw new ProviderLoaderException(e, service.getName(), providerName); } } return null; } /** Return true if the ident matches the Plugin annotation for the class */ static boolean matchesProviderDeclaration(final ProviderIdent ident, final Class<?> cls) throws PluginException { final Plugin annotation = getPluginMetadata(cls); return ident.getFirst().equals(annotation.service()) && ident.getSecond().equals(annotation.name()); } /** Return true if the ident matches the Plugin annotation for the class */ static ProviderIdent getProviderDeclaration(final Class<?> cls) throws PluginException { final Plugin annotation = getPluginMetadata(cls); return new ProviderIdent(annotation.service(), annotation.name()); } Attributes mainAttributes; /** Get the declared list of provider classnames for the file */ public String[] getClassnames() { final Attributes attributes = getMainAttributes(); if (null == attributes) { return null; } final String value = attributes.getValue(RUNDECK_PLUGIN_CLASSNAMES); if (null == value) { return null; } return value.split(","); } private String getResourcesBasePath() { final Attributes attributes = getMainAttributes(); if (null != attributes) { final String dir = attributes.getValue(RUNDECK_PLUGIN_RESOURCES_DIR); if (null != dir) { // list resources in the dir of the jar return dir; } } return RESOURCES_DIR_DEFAULT; } private List<String> getPluginResourcesList() { final Attributes attributes = getMainAttributes(); if (null != attributes) { final String value = attributes.getValue(RUNDECK_PLUGIN_RESOURCES); if (null != value) { return Arrays.asList(value.split(" *, *")); } } return null; } /** * Get the version of the plugin, not the file version * * @return */ public String getPluginVersion() { Attributes mainAttributes = getMainAttributes(); return mainAttributes.getValue(RUNDECK_PLUGIN_VERSION); } /** return the main attributes from the jar manifest */ private Attributes getMainAttributes() { if (null == mainAttributes) { mainAttributes = getJarMainAttributes(pluginJar); } return mainAttributes; } /** Get the main attributes for the jar file */ private static Attributes getJarMainAttributes(final File file) { debug("getJarMainAttributes: " + file); try { try (final JarInputStream jarInputStream = new JarInputStream(new FileInputStream(file))) { return jarInputStream.getManifest().getMainAttributes(); } } catch (IOException e) { return null; } } /** * Attempt to create an instance of thea provider for the given service * * @param cls class * @return created instance */ static <T, X extends T> T createProviderForClass( final PluggableService<T> service, final Class<X> cls) throws PluginException, ProviderCreationException { debug("Try loading provider " + cls.getName()); final Plugin annotation = getPluginMetadata(cls); final String pluginname = annotation.name(); if (!service.isValidProviderClass(cls)) { throw new PluginException( "Class " + cls.getName() + " was not a valid plugin class for service: " + service.getName() + ". Expected class " + cls.getName() + ", with a public constructor with no parameter"); } debug("Succeeded loading plugin " + cls.getName() + " for service: " + service.getName()); return service.createProviderInstance(cls, pluginname); } private static void debug(final String s) { if (log.isDebugEnabled()) { log.debug(s); } } /** Get the Plugin annotation for the class */ static Plugin getPluginMetadata(final Class<?> cls) throws PluginException { // try to get plugin provider name final String pluginname; if (!cls.isAnnotationPresent(Plugin.class)) { throw new PluginException("No Plugin annotation was found for the class: " + cls.getName()); } final Plugin annotation = (Plugin) cls.getAnnotation(Plugin.class); pluginname = annotation.name(); if (null == pluginname || "".equals(pluginname)) { throw new PluginException( "Plugin annotation 'name' cannot be empty for the class: " + cls.getName()); } // get service name from annotation final String servicename = annotation.service(); if (null == servicename || "".equals(servicename)) { throw new PluginException( "Plugin annotation 'service' cannot be empty for the class: " + cls.getName()); } return annotation; } private Map<String, Class<?>> classCache = new HashMap<String, Class<?>>(); /** opened classloaders that need to be closed upon expiration of this loader */ private Map<File, Closeable> classLoaders = new HashMap<>(); private Set<File> cachedFiles = new HashSet<>(); /** * @return true if the other jar is a copy of the pluginJar based on names returned by * generateCachedJarName */ protected boolean isEquivalentPluginJar(File other) { return other.getName().replaceFirst("\\d+-\\d+-", "").equals(pluginJar.getName()); } static final AtomicLong counter = new AtomicLong(0); /** @return a generated name for the pluginJar using the last modified timestamp */ protected String generateCachedJarIdentity() { Date mtime = new Date(pluginJar.lastModified()); return String.format( "%s-%d", cachedJarTimestampFormatter.format(mtime), counter.getAndIncrement()); } /** @return a generated name for the pluginJar using the last modified timestamp */ protected String generateCachedJarName(String ident) { return String.format("%s-%s", ident, pluginJar.getName()); } /** @return a generated name for the pluginJar using the last modified timestamp */ protected File generateCachedJarDir(String ident) { File dir = new File(getFileCacheDir(), ident); if (!dir.mkdirs()) { debug("Could not create dir for cachedjar libs: " + dir); } return dir; } /** * Creates a single cached version of the pluginJar located within pluginJarCacheDirectory * deleting all existing versions of pluginJar * * @param jarName */ protected File createCachedJar(final File dir, final String jarName) throws PluginException { File cachedJar; try { cachedJar = new File(dir, jarName); cachedJar.deleteOnExit(); FileUtils.fileCopy(pluginJar, cachedJar, true); } catch (IOException e) { throw new PluginException(e); } return cachedJar; } /** Load a class from the jar file by name */ private Class<?> loadClass(final String classname) throws PluginException { if (null == classname) { throw new IllegalArgumentException("A null java class name was specified."); } if (null != classCache.get(classname)) { debug("(loadClass) " + classname + ": " + pluginJar); return classCache.get(classname); } CachedJar cachedJar1 = getCachedJar(); debug("loadClass! " + classname + ": " + cachedJar1.getCachedJar()); final Class<?> cls; final URLClassLoader urlClassLoader = cachedJar1.getClassLoader(); try { cls = Class.forName(classname, true, urlClassLoader); classCache.put(classname, cls); } catch (ClassNotFoundException e) { throw new PluginException("Class not found: " + classname, e); } catch (Throwable t) { throw new PluginException("Error loading class: " + classname, t); } return cls; } private CachedJar cachedJar; private Date loadedDate = null; private synchronized JarPluginProviderLoader.CachedJar getCachedJar() throws PluginException { if (null == cachedJar) { synchronized (this) { if (null == cachedJar) { this.loadedDate = new Date(); String itemIdent = generateCachedJarIdentity(); String jarName = generateCachedJarName(itemIdent); File dir = generateCachedJarDir(itemIdent); File cachedJar = createCachedJar(dir, jarName); cachedFiles.add(dir); // if jar manifest declares secondary lib deps, expand lib into cachedir, and setup // classloader // to use the libs Collection<File> extlibs = null; try { extlibs = extractDependentLibs(dir); } catch (IOException e) { throw new PluginException("Unable to expand plugin libs: " + e.getMessage(), e); } ZipResourceLoader loader = null; if (supportsResources(getPluginVersion())) { loader = new ZipResourceLoader( new File(dir, "resources"), cachedJar, getPluginResourcesList(), getResourcesBasePath()); try { loader.extractResources(); } catch (IOException e) { throw new PluginException("Unable to expand plugin libs: " + e.getMessage(), e); } } this.cachedJar = new CachedJar(dir, cachedJar, extlibs, loader); } } } return cachedJar; } /** * Extract the dependent libs and return the extracted jar files * * @return the collection of extracted files */ private Collection<File> extractDependentLibs(final File cachedir) throws IOException { final Attributes attributes = getMainAttributes(); if (null == attributes) { debug("no manifest attributes"); return null; } final ArrayList<File> files = new ArrayList<File>(); final String libs = attributes.getValue(RUNDECK_PLUGIN_LIBS); if (null != libs) { debug("jar libs listed: " + libs + " for file: " + pluginJar); if (!cachedir.isDirectory()) { if (!cachedir.mkdirs()) { debug("Failed to create cachedJar dir for dependent libs: " + cachedir); } } final String[] libsarr = libs.split(" "); extractJarContents(libsarr, cachedir); for (final String s : libsarr) { File libFile = new File(cachedir, s); libFile.deleteOnExit(); files.add(libFile); } } else { debug("no jar libs listed in manifest: " + pluginJar); } return files; } /** * Extract specific entries from the jar to a destination directory. Creates the destination * directory if it does not exist * * @param entries the entries to extract * @param destdir destination directory */ private void extractJarContents(final String[] entries, final File destdir) throws IOException { if (!destdir.exists()) { if (!destdir.mkdir()) { log.warn("Unable to create cache dir for plugin: " + destdir.getAbsolutePath()); } } debug("extracting lib files from jar: " + pluginJar); for (final String path : entries) { debug( "Expand zip " + pluginJar.getAbsolutePath() + " to dir: " + destdir + ", file: " + path); ZipUtil.extractZipFile(pluginJar.getAbsolutePath(), destdir, path); } } /** Basename of the file */ String getFileBasename() { return basename(pluginJar); } /** Get basename of a file */ private static String basename(final File file) { final String name = file.getName(); return name.substring(0, name.lastIndexOf(".")); } /** Get the cache dir for use for this file */ File getFileCacheDir() { return new File(cachedir, getFileBasename()); } /** Return true if the file has a class that provides the ident. */ public synchronized boolean isLoaderFor(final ProviderIdent ident) { final String[] strings = getClassnames(); for (final String classname : strings) { try { if (matchesProviderDeclaration(ident, loadClass(classname))) { return true; } } catch (PluginException e) { e.printStackTrace(); } } return false; } public synchronized List<ProviderIdent> listProviders() { final ArrayList<ProviderIdent> providerIdents = new ArrayList<ProviderIdent>(); final String[] strings = getClassnames(); for (final String classname : strings) { try { providerIdents.add(getProviderDeclaration(loadClass(classname))); } catch (PluginException e) { e.printStackTrace(); } } return providerIdents; } /** Remove any cache dir for the file */ private synchronized boolean removeScriptPluginCache() { final File fileExpandedDir = getFileCacheDir(); if (null != fileExpandedDir && fileExpandedDir.exists()) { debug("removeScriptPluginCache: " + fileExpandedDir); return FileUtils.deleteDir(fileExpandedDir); } return true; } /** Expire the loader cache item */ public void expire() { debug("expire jar provider loader for: " + pluginJar); removeScriptPluginCache(); classCache.clear(); this.cachedJar = null; // close loaders for (File file : classLoaders.keySet()) { try { debug("expire classLoaders for: " + file); classLoaders.remove(file).close(); } catch (IOException e) { e.printStackTrace(); } } // remove cache files for (File file : cachedFiles) { debug("remove cache dir: " + file); FileUtils.deleteDir(file); } } @Override public boolean equals(final Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } final JarPluginProviderLoader that = (JarPluginProviderLoader) o; if (classCache != null ? !classCache.equals(that.classCache) : that.classCache != null) { return false; } if (!pluginJar.equals(that.pluginJar)) { return false; } if (mainAttributes != null ? !mainAttributes.equals(that.mainAttributes) : that.mainAttributes != null) { return false; } if (pluginProviderDefs != null ? !pluginProviderDefs.equals(that.pluginProviderDefs) : that.pluginProviderDefs != null) { return false; } return true; } @Override public int hashCode() { int result = pluginJar.hashCode(); result = 31 * result + (pluginProviderDefs != null ? pluginProviderDefs.hashCode() : 0); result = 31 * result + (mainAttributes != null ? mainAttributes.hashCode() : 0); result = 31 * result + (classCache != null ? classCache.hashCode() : 0); return result; } /** Return true if the file is a valid jar plugin file */ public static boolean isValidJarPlugin(final File file) { try { try (final JarInputStream jarInputStream = new JarInputStream(new FileInputStream(file))) { final Manifest manifest = jarInputStream.getManifest(); if (null == manifest) { return false; } final Attributes mainAttributes = manifest.getMainAttributes(); validateJarManifest(mainAttributes); } return true; } catch (IOException | InvalidManifestException e) { log.error(file.getAbsolutePath() + ": " + e.getMessage()); return false; } } /** Validate whether the jar file has a valid manifest, throw exception if invalid */ static void validateJarManifest(final Attributes mainAttributes) throws InvalidManifestException { final String value1 = mainAttributes.getValue(RUNDECK_PLUGIN_ARCHIVE); final String plugvers = mainAttributes.getValue(RUNDECK_PLUGIN_VERSION); final String plugclassnames = mainAttributes.getValue(RUNDECK_PLUGIN_CLASSNAMES); if (null == value1) { throw new InvalidManifestException( "Jar plugin manifest attribute missing: " + RUNDECK_PLUGIN_ARCHIVE); } else if (!"true".equals(value1)) { throw new InvalidManifestException(RUNDECK_PLUGIN_ARCHIVE + " was not 'true': " + value1); } if (null == plugvers) { throw new InvalidManifestException( "Jar plugin manifest attribute missing: " + RUNDECK_PLUGIN_VERSION); } final VersionCompare pluginVersion = VersionCompare.forString(plugvers); if (!pluginVersion.atLeast(LOWEST_JAR_PLUGIN_VERSION)) { throw new InvalidManifestException( "Unsupported plugin version: " + RUNDECK_PLUGIN_VERSION + ": " + plugvers); } if (null == plugclassnames) { throw new InvalidManifestException( "Jar plugin manifest attribute missing: " + RUNDECK_PLUGIN_CLASSNAMES); } } static class InvalidManifestException extends Exception { public InvalidManifestException(String s) { super(s); } } /** * Return the version string metadata value for the plugin file, or null if it is not available or * could not loaded * * @param file plugin file * @return version string */ static String getVersionForFile(final File file) { return loadManifestAttribute(file, RUNDECK_PLUGIN_FILE_VERSION); } /** * Return true if the jar attributes declare it should load local dependency classes first. * * @param file plugin file * @return true if plugin libs load first is set */ static boolean getLoadLocalLibsFirstForFile(final File file) { Attributes attributes = loadMainAttributes(file); if (null == attributes) { return false; } boolean loadFirstDefault = true; String loadFirst = attributes.getValue(RUNDECK_PLUGIN_LIBS_LOAD_FIRST); if (null != loadFirst) { return Boolean.valueOf(loadFirst); } return loadFirstDefault; } private static Attributes loadMainAttributes(final File file) { Attributes mainAttributes = null; try { try (final JarInputStream jarInputStream = new JarInputStream(new FileInputStream(file))) { final Manifest manifest = jarInputStream.getManifest(); if (null != manifest) { mainAttributes = manifest.getMainAttributes(); } } } catch (IOException e) { e.printStackTrace(System.err); log.warn(e.getMessage() + ": " + file.getAbsolutePath()); } return mainAttributes; } private static String loadManifestAttribute(final File file, final String attribute) { String value = null; final Attributes mainAttributes = loadMainAttributes(file); if (null != mainAttributes) { value = mainAttributes.getValue(attribute); } return value; } private class CachedJar { private File dir; private File cachedJar; private Collection<File> depLibs; private URLClassLoader classLoader; private PluginResourceLoader resourcesLoader; public File getDir() { return dir; } public File getCachedJar() { return cachedJar; } public CachedJar( File dir, File cachedJar, Collection<File> depLibs, PluginResourceLoader resourcesLoader) throws PluginException { this.dir = dir; this.cachedJar = cachedJar; this.depLibs = depLibs; this.resourcesLoader = resourcesLoader; } public Collection<File> getDepLibs() { return depLibs; } public URLClassLoader getClassLoader() throws PluginException { if (null == classLoader) { synchronized (this) { if (null == classLoader) { classLoader = buildClassLoader(); } } } return classLoader; } private URLClassLoader buildClassLoader() throws PluginException { ClassLoader parent = JarPluginProviderLoader.class.getClassLoader(); try { final URL url = getCachedJar().toURI().toURL(); final URL[] urlarray; if (null != getDepLibs() && getDepLibs().size() > 0) { final ArrayList<URL> urls = new ArrayList<URL>(); urls.add(url); for (final File extlib : getDepLibs()) { urls.add(extlib.toURI().toURL()); } urlarray = urls.toArray(new URL[urls.size()]); } else { urlarray = new URL[] {url}; } URLClassLoader loaded = loadLibsFirst ? LocalFirstClassLoader.newInstance(urlarray, parent) : URLClassLoader.newInstance(urlarray, parent); classLoaders.put(getCachedJar(), loaded); return loaded; } catch (MalformedURLException e) { throw new PluginException("Error creating classloader for " + cachedJar, e); } } } @Override public String getFilename() { return pluginJar.getName(); } @Override public File getFile() { return pluginJar; } @Override public String getPluginAuthor() { Attributes mainAttributes = getMainAttributes(); return mainAttributes.getValue(RUNDECK_PLUGIN_AUTHOR); } @Override public String getPluginFileVersion() { Attributes mainAttributes = getMainAttributes(); return mainAttributes.getValue(RUNDECK_PLUGIN_FILE_VERSION); } @Override public String getPluginUrl() { Attributes mainAttributes = getMainAttributes(); return mainAttributes.getValue(RUNDECK_PLUGIN_URL); } @Override public Date getPluginDate() { Attributes mainAttributes = getMainAttributes(); String value = mainAttributes.getValue(RUNDECK_PLUGIN_DATE); if (null != value) { try { return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX").parse(value); } catch (ParseException e) { } } return null; } @Override public Date getDateLoaded() { return loadedDate; } }
private boolean supportsResources(final String pluginVersion) { return VersionCompare.forString(pluginVersion).atLeast(SUPPORTS_RESOURCES_PLUGIN_VERSION); }