/*
* Copyright © 2015 Cask Data, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package co.cask.cdap.internal.app.runtime.artifact;
import co.cask.cdap.api.Config;
import co.cask.cdap.api.annotation.Description;
import co.cask.cdap.api.annotation.Name;
import co.cask.cdap.api.annotation.Plugin;
import co.cask.cdap.api.app.Application;
import co.cask.cdap.api.artifact.ApplicationClass;
import co.cask.cdap.api.artifact.ArtifactClasses;
import co.cask.cdap.api.artifact.ArtifactId;
import co.cask.cdap.api.data.schema.Schema;
import co.cask.cdap.api.data.schema.UnsupportedTypeException;
import co.cask.cdap.api.plugin.EndpointPluginContext;
import co.cask.cdap.api.plugin.PluginClass;
import co.cask.cdap.api.plugin.PluginConfig;
import co.cask.cdap.api.plugin.PluginPropertyField;
import co.cask.cdap.app.program.ManifestFields;
import co.cask.cdap.common.InvalidArtifactException;
import co.cask.cdap.common.conf.CConfiguration;
import co.cask.cdap.common.conf.Constants;
import co.cask.cdap.common.io.Locations;
import co.cask.cdap.common.lang.jar.BundleJarUtil;
import co.cask.cdap.common.utils.DirUtils;
import co.cask.cdap.internal.app.runtime.plugin.PluginInstantiator;
import co.cask.cdap.internal.io.ReflectionSchemaGenerator;
import co.cask.cdap.proto.Id;
import com.google.common.base.Function;
import com.google.common.base.Predicate;
import com.google.common.base.Throwables;
import com.google.common.collect.AbstractIterator;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterators;
import com.google.common.collect.Maps;
import com.google.common.primitives.Primitives;
import com.google.common.reflect.TypeToken;
import org.apache.twill.filesystem.Location;
import org.objectweb.asm.AnnotationVisitor;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.zip.ZipException;
import javax.annotation.Nullable;
import javax.ws.rs.Path;
/**
* Inspects a jar file to determine metadata about the artifact.
*/
final class ArtifactInspector {
private static final Logger LOG = LoggerFactory.getLogger(ArtifactInspector.class);
private final CConfiguration cConf;
private final ArtifactClassLoaderFactory artifactClassLoaderFactory;
private final ReflectionSchemaGenerator schemaGenerator;
ArtifactInspector(CConfiguration cConf, ArtifactClassLoaderFactory artifactClassLoaderFactory) {
this.cConf = cConf;
this.artifactClassLoaderFactory = artifactClassLoaderFactory;
this.schemaGenerator = new ReflectionSchemaGenerator(false);
}
/**
* Inspect the given artifact to determine the classes contained in the artifact.
*
* @param artifactId the id of the artifact to inspect
* @param artifactFile the artifact file
* @param parentClassLoader the parent classloader to use when inspecting plugins contained in the artifact.
* For example, a ProgramClassLoader created from the artifact the input artifact extends
* @return metadata about the classes contained in the artifact
* @throws IOException if there was an exception opening the jar file
* @throws InvalidArtifactException if the artifact is invalid. For example, if the application main class is not
* actually an Application.
*/
ArtifactClasses inspectArtifact(Id.Artifact artifactId, File artifactFile,
ClassLoader parentClassLoader) throws IOException, InvalidArtifactException {
ArtifactClasses.Builder builder = inspectApplications(artifactId, ArtifactClasses.builder(),
Locations.toLocation(artifactFile));
File tmpDir = new File(cConf.get(Constants.CFG_LOCAL_DATA_DIR),
cConf.get(Constants.AppFabric.TEMP_DIR)).getAbsoluteFile();
File stageDir = DirUtils.createTempDir(tmpDir);
try (PluginInstantiator pluginInstantiator = new PluginInstantiator(cConf, parentClassLoader, stageDir)) {
pluginInstantiator.addArtifact(Locations.toLocation(artifactFile), artifactId.toArtifactId());
inspectPlugins(builder, artifactFile, artifactId.toArtifactId(), pluginInstantiator);
} finally {
try {
DirUtils.deleteDirectoryContents(stageDir);
} catch (IOException e) {
LOG.warn("Exception raised while deleting directory {}", stageDir, e);
}
}
return builder.build();
}
private ArtifactClasses.Builder inspectApplications(Id.Artifact artifactId,
ArtifactClasses.Builder builder,
Location artifactLocation) throws IOException,
InvalidArtifactException {
// right now we force users to include the application main class as an attribute in their manifest,
// which forces them to have a single application class.
// in the future, we may want to let users do this or maybe specify a list of classes or
// a package that will be searched for applications, to allow multiple applications in a single artifact.
String mainClassName;
try {
Manifest manifest = BundleJarUtil.getManifest(artifactLocation);
if (manifest == null) {
return builder;
}
Attributes manifestAttributes = manifest.getMainAttributes();
if (manifestAttributes == null) {
return builder;
}
mainClassName = manifestAttributes.getValue(ManifestFields.MAIN_CLASS);
} catch (ZipException e) {
throw new InvalidArtifactException(String.format(
"Couldn't unzip artifact %s, please check it is a valid jar file.", artifactId), e);
}
if (mainClassName != null) {
try (CloseableClassLoader artifactClassLoader = artifactClassLoaderFactory.createClassLoader(artifactLocation)) {
Object appMain = artifactClassLoader.loadClass(mainClassName).newInstance();
if (!(appMain instanceof Application)) {
// we don't want to error here, just don't record an application class.
// possible for 3rd party plugin artifacts to have the main class set
return builder;
}
Application app = (Application) appMain;
java.lang.reflect.Type configType;
// if the user parameterized their application, like 'xyz extends Application<T>',
// we can deserialize the config into that object. Otherwise it'll just be a Config
try {
configType = Artifacts.getConfigType(app.getClass());
} catch (Exception e) {
throw new InvalidArtifactException(String.format(
"Could not resolve config type for Application class %s in artifact %s. " +
"The type must extend Config and cannot be parameterized.", mainClassName, artifactId));
}
Schema configSchema = configType == Config.class ? null : schemaGenerator.generate(configType);
builder.addApp(new ApplicationClass(mainClassName, "", configSchema));
} catch (ClassNotFoundException e) {
throw new InvalidArtifactException(String.format(
"Could not find Application main class %s in artifact %s.", mainClassName, artifactId));
} catch (UnsupportedTypeException e) {
throw new InvalidArtifactException(String.format(
"Config for Application %s in artifact %s has an unsupported schema. " +
"The type must extend Config and cannot be parameterized.", mainClassName, artifactId));
} catch (InstantiationException | IllegalAccessException e) {
throw new InvalidArtifactException(String.format(
"Could not instantiate Application class %s in artifact %s.", mainClassName, artifactId), e);
}
}
return builder;
}
/**
* Inspects the plugin file and extracts plugin classes information.
*/
private ArtifactClasses.Builder inspectPlugins(ArtifactClasses.Builder builder, File artifactFile,
ArtifactId artifactId, PluginInstantiator pluginInstantiator)
throws IOException, InvalidArtifactException {
// See if there are export packages. Plugins should be in those packages
Set<String> exportPackages = getExportPackages(artifactFile);
if (exportPackages.isEmpty()) {
return builder;
}
try {
ClassLoader pluginClassLoader = pluginInstantiator.getArtifactClassLoader(artifactId);
for (Class<?> cls : getPluginClasses(exportPackages, pluginClassLoader)) {
Plugin pluginAnnotation = cls.getAnnotation(Plugin.class);
if (pluginAnnotation == null) {
continue;
}
Map<String, PluginPropertyField> pluginProperties = Maps.newHashMap();
try {
String configField = getProperties(TypeToken.of(cls), pluginProperties);
Set<String> pluginEndpoints = getPluginEndpoints(cls);
PluginClass pluginClass = new PluginClass(pluginAnnotation.type(), getPluginName(cls),
getPluginDescription(cls), cls.getName(),
configField, pluginProperties, pluginEndpoints);
builder.addPlugin(pluginClass);
} catch (UnsupportedTypeException e) {
LOG.warn("Plugin configuration type not supported. Plugin ignored. {}", cls, e);
}
}
} catch (Throwable t) {
throw new InvalidArtifactException(String.format(
"Class could not be found while inspecting artifact for plugins. " +
"Please check dependencies are available, and that the correct parent artifact was specified. " +
"Error class: %s, message: %s.", t.getClass(), t.getMessage()), t);
}
return builder;
}
/**
* Returns the set of package names that are declared in "Export-Package" in the jar file Manifest.
*/
private Set<String> getExportPackages(File file) throws IOException {
try (JarFile jarFile = new JarFile(file)) {
return ManifestFields.getExportPackages(jarFile.getManifest());
}
}
/**
* Returns an {@link Iterable} of class name that are under the given list of package names that are loadable
* through the plugin ClassLoader.
*/
private Iterable<Class<?>> getPluginClasses(final Iterable<String> packages, final ClassLoader pluginClassLoader) {
return new Iterable<Class<?>>() {
@Override
public Iterator<Class<?>> iterator() {
final Iterator<String> packageIterator = packages.iterator();
return new AbstractIterator<Class<?>>() {
Iterator<String> classIterator = ImmutableList.<String>of().iterator();
String currentPackage;
@Override
protected Class<?> computeNext() {
while (!classIterator.hasNext()) {
if (!packageIterator.hasNext()) {
return endOfData();
}
currentPackage = packageIterator.next();
try {
// Gets all package resource URL for the given package
String resourceName = currentPackage.replace('.', File.separatorChar);
Enumeration<URL> resources = pluginClassLoader.getResources(resourceName);
List<Iterator<String>> iterators = new ArrayList<>();
// Go though all available resources and collect all class names that are plugin classes.
while (resources.hasMoreElements()) {
URL packageResource = resources.nextElement();
// Only inspect classes in the top level jar file for Plugins.
// The jar manifest may have packages in Export-Package that are loadable from the bundled jar files,
// which is for classloading purpose. Those classes won't be inspected for plugin classes.
// There should be exactly one of resource that match, because it maps to a directory on the FS.
if (packageResource.getProtocol().equals("file")) {
Iterator<String> classFiles = DirUtils.list(new File(packageResource.toURI()), "class").iterator();
// Transform class file into class name and filter by @Plugin class only
iterators.add(Iterators.filter(
Iterators.transform(classFiles, new Function<String, String>() {
@Override
public String apply(String input) {
return getClassName(currentPackage, input);
}
}), new Predicate<String>() {
@Override
public boolean apply(String className) {
return isPlugin(className, pluginClassLoader);
}
}));
}
}
if (!iterators.isEmpty()) {
classIterator = Iterators.concat(iterators.iterator());
}
} catch (Exception e) {
// Cannot happen
throw Throwables.propagate(e);
}
}
try {
return pluginClassLoader.loadClass(classIterator.next());
} catch (ClassNotFoundException | NoClassDefFoundError e) {
// Cannot happen, since the class name is from the list of the class files under the classloader.
throw Throwables.propagate(e);
}
}
};
}
};
}
/**
* Extracts and returns name of the plugin.
*/
private String getPluginName(Class<?> cls) {
Name annotation = cls.getAnnotation(Name.class);
return annotation == null || annotation.value().isEmpty() ? cls.getName() : annotation.value();
}
/**
* Extracts and returns set of endpoints in the plugin.
* @throws IllegalArgumentException if there are duplicate endpoints found or
* if the number of arguments is not 1 or 2, or if type of 2nd argument is not an EndpointPluginContext.
*/
private Set<String> getPluginEndpoints(Class<?> cls) throws IllegalArgumentException {
Set<String> endpoints = new HashSet<>();
Method[] methods = cls.getMethods();
for (Method method : methods) {
Path pathAnnotation = method.getAnnotation(Path.class);
// method should have path annotation else continue
if (pathAnnotation != null) {
if (!endpoints.add(pathAnnotation.value())) {
// if the endpoint already exists throw an exception saying, plugin has two methods with same endpoint name.
throw new IllegalArgumentException(String.format("Two Endpoints with same name : %s found in Plugin : %s",
pathAnnotation.value(), getPluginName(cls)));
}
// check that length of method parameters is 1 or 2. if length is 2,
// check that 2nd param is of type EndpointPluginContext
if (!(method.getParameterTypes().length == 1 || method.getParameterTypes().length == 2)) {
throw new IllegalArgumentException(
String.format("Endpoint parameters can only be of length 1 or 2, " +
"found endpoint %s with %s parameters",
pathAnnotation.value(), method.getParameterTypes().length));
}
if (method.getParameterTypes().length == 2 &&
!EndpointPluginContext.class.isAssignableFrom(method.getParameterTypes()[1])) {
throw new IllegalArgumentException(
String.format("2nd parameter of endpoint should be EndpointPluginContext, " +
"%s is not of type %s in endpoint %s",
method.getParameterTypes()[1], EndpointPluginContext.class.getName(),
pathAnnotation.value()));
}
}
}
return endpoints;
}
/**
* Returns description for the plugin.
*/
private String getPluginDescription(Class<?> cls) {
Description annotation = cls.getAnnotation(Description.class);
return annotation == null ? "" : annotation.value();
}
/**
* Constructs the fully qualified class name based on the package name and the class file name.
*/
private String getClassName(String packageName, String classFileName) {
return packageName + "." + classFileName.substring(0, classFileName.length() - ".class".length());
}
/**
* Gets all config properties for the given plugin.
*
* @return the name of the config field in the plugin class or {@code null} if the plugin doesn't have a config field
*/
@Nullable
private String getProperties(TypeToken<?> pluginType,
Map<String, PluginPropertyField> result) throws UnsupportedTypeException {
// Get the config field
for (TypeToken<?> type : pluginType.getTypes().classes()) {
for (Field field : type.getRawType().getDeclaredFields()) {
TypeToken<?> fieldType = TypeToken.of(field.getGenericType());
if (PluginConfig.class.isAssignableFrom(fieldType.getRawType())) {
// Pick up all config properties
inspectConfigField(fieldType, result);
return field.getName();
}
}
}
return null;
}
/**
* Inspects the plugin config class and build up a map for {@link PluginPropertyField}.
*
* @param configType type of the config class
* @param result map for storing the result
* @throws UnsupportedTypeException if a field type in the config class is not supported
*/
private void inspectConfigField(TypeToken<?> configType,
Map<String, PluginPropertyField> result) throws UnsupportedTypeException {
for (TypeToken<?> type : configType.getTypes().classes()) {
if (PluginConfig.class.equals(type.getRawType())) {
break;
}
for (Field field : type.getRawType().getDeclaredFields()) {
int modifiers = field.getModifiers();
if (Modifier.isTransient(modifiers) || Modifier.isStatic(modifiers) || field.isSynthetic()) {
continue;
}
PluginPropertyField property = createPluginProperty(field, type);
if (result.containsKey(property.getName())) {
throw new IllegalArgumentException("Plugin config with name " + property.getName()
+ " already defined in " + configType.getRawType());
}
result.put(property.getName(), property);
}
}
}
/**
* Creates a {@link PluginPropertyField} based on the given field.
*/
private PluginPropertyField createPluginProperty(Field field,
TypeToken<?> resolvingType) throws UnsupportedTypeException {
TypeToken<?> fieldType = resolvingType.resolveType(field.getGenericType());
Class<?> rawType = fieldType.getRawType();
Name nameAnnotation = field.getAnnotation(Name.class);
Description descAnnotation = field.getAnnotation(Description.class);
String name = nameAnnotation == null ? field.getName() : nameAnnotation.value();
String description = descAnnotation == null ? "" : descAnnotation.value();
if (rawType.isPrimitive()) {
return new PluginPropertyField(name, description, rawType.getName(), true);
}
rawType = Primitives.unwrap(rawType);
if (!rawType.isPrimitive() && !String.class.equals(rawType)) {
throw new UnsupportedTypeException("Only primitive and String types are supported");
}
boolean required = true;
for (Annotation annotation : field.getAnnotations()) {
if (annotation.annotationType().getName().endsWith(".Nullable")) {
required = false;
break;
}
}
return new PluginPropertyField(name, description, rawType.getSimpleName().toLowerCase(), required);
}
/**
* Detects if a class is annotated with {@link Plugin} without loading the class.
*
* @param className name of the class
* @param classLoader ClassLoader for loading the class file of the given class
* @return true if the given class is annotated with {@link Plugin}
*/
private boolean isPlugin(String className, ClassLoader classLoader) {
try (InputStream is = classLoader.getResourceAsStream(className.replace('.', '/') + ".class")) {
if (is == null) {
return false;
}
// Use ASM to inspect the class bytecode to see if it is annotated with @Plugin
final boolean[] isPlugin = new boolean[1];
ClassReader cr = new ClassReader(is);
cr.accept(new ClassVisitor(Opcodes.ASM5) {
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
if (Plugin.class.getName().equals(Type.getType(desc).getClassName()) && visible) {
isPlugin[0] = true;
}
return super.visitAnnotation(desc, visible);
}
}, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
return isPlugin[0];
} catch (IOException e) {
// If failed to open the class file, then it cannot be a plugin
LOG.warn("Failed to open class file for {}", className, e);
return false;
}
}
}