co.cask.cdap.internal.app.runtime.artifact.ArtifactInspector.java Source code

Java tutorial

Introduction

Here is the source code for co.cask.cdap.internal.app.runtime.artifact.ArtifactInspector.java

Source

/*
 * 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;
        }
    }
}