ValidateMojo.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2015 wcm.io
 * %%
 * 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.
 * #L%
 */
package io.wcm.devops.conga.tooling.maven.plugin;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.SortedSet;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.maven.artifact.resolver.ResolutionErrorHandler;
import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.plugins.annotations.ResolutionScope;
import org.apache.maven.project.MavenProject;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.repository.RemoteRepository;
import org.sonatype.plexus.build.incremental.BuildContext;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import io.wcm.devops.conga.generator.GeneratorException;
import io.wcm.devops.conga.generator.GeneratorOptions;
import io.wcm.devops.conga.generator.UrlFileManager;
import io.wcm.devops.conga.generator.handlebars.HandlebarsManager;
import io.wcm.devops.conga.generator.spi.context.PluginContextOptions;
import io.wcm.devops.conga.generator.spi.context.UrlFilePluginContext;
import io.wcm.devops.conga.generator.util.PluginManager;
import io.wcm.devops.conga.generator.util.PluginManagerImpl;
import io.wcm.devops.conga.model.environment.Environment;
import io.wcm.devops.conga.model.reader.EnvironmentReader;
import io.wcm.devops.conga.model.reader.RoleReader;
import io.wcm.devops.conga.model.role.Role;
import io.wcm.devops.conga.resource.Resource;
import io.wcm.devops.conga.resource.ResourceCollection;
import io.wcm.devops.conga.resource.ResourceInfo;
import io.wcm.devops.conga.resource.ResourceLoader;
import io.wcm.devops.conga.tooling.maven.plugin.util.ClassLoaderUtil;
import io.wcm.devops.conga.tooling.maven.plugin.util.MavenContext;
import io.wcm.devops.conga.tooling.maven.plugin.util.PathUtil;
import io.wcm.devops.conga.tooling.maven.plugin.util.VersionInfoUtil;
import io.wcm.devops.conga.tooling.maven.plugin.validation.DefinitionValidator;
import io.wcm.devops.conga.tooling.maven.plugin.validation.ModelValidator;
import io.wcm.devops.conga.tooling.maven.plugin.validation.NoValueProviderInRoleValidator;
import io.wcm.devops.conga.tooling.maven.plugin.validation.RoleTemplateFileValidator;
import io.wcm.devops.conga.tooling.maven.plugin.validation.TemplateValidator;

/**
 * Validates definitions by trying to parse them with model reader or compile them via handlebars.
 * Validates that the CONGA maven plugin version and CONGA plugin versions match or are newer than those versions
 * used when generating the dependency artifacts.
 */
@Mojo(name = "validate", defaultPhase = LifecyclePhase.VALIDATE, requiresProject = true, threadSafe = true,
    requiresDependencyResolution = ResolutionScope.COMPILE)
public class ValidateMojo extends AbstractCongaMojo {

  /**
   * Selected environments to validate.
   */
  @Parameter(property = "conga.environments")
  private String[] environments;

  @Parameter(property = "project", required = true, readonly = true)
  private MavenProject project;

  @Component
  private org.apache.maven.repository.RepositorySystem repositorySystem;
  @Component
  private ResolutionErrorHandler resolutionErrorHandler;
  @Component
  private RepositorySystem repoSystem;
  @Component
  private BuildContext buildContext;
  @Parameter(defaultValue = "${repositorySystemSession}", readonly = true)
  private RepositorySystemSession repoSession;
  @Parameter(defaultValue = "${project.remoteProjectRepositories}", readonly = true)
  private List<RemoteRepository> remoteRepos;
  @Parameter(defaultValue = "${session}", readonly = true, required = false)
  private MavenSession session;

  @Override
  public void execute() throws MojoExecutionException, MojoFailureException {
    List<URL> mavenProjectClasspathUrls = ClassLoaderUtil.getMavenProjectClasspathUrls(project);
    ClassLoader mavenProjectClassLoader = ClassLoaderUtil.buildClassLoader(mavenProjectClasspathUrls);
    ResourceLoader mavenProjectResourceLoader = new ResourceLoader(mavenProjectClassLoader);

    ResourceCollection roleDir = mavenProjectResourceLoader.getResourceCollection(ResourceLoader.FILE_PREFIX + getRoleDir());
    ResourceCollection templateDir = mavenProjectResourceLoader.getResourceCollection(ResourceLoader.FILE_PREFIX + getTemplateDir());
    ResourceCollection environmentDir = mavenProjectResourceLoader.getResourceCollection(ResourceLoader.FILE_PREFIX + getEnvironmentDir());

    // validate role definition syntax
    validateFiles(roleDir, roleDir, new ModelValidator<Role>("Role", new RoleReader()));

    PluginManager pluginManager = new PluginManagerImpl();

    MavenContext mavenContext = new MavenContext()
        .project(project)
        .session(session)
        .setRepositorySystem(repositorySystem)
        .resolutionErrorHandler(resolutionErrorHandler)
        .buildContext(buildContext)
        .log(getLog())
        .repoSystem(repoSystem)
        .repoSession(repoSession)
        .remoteRepos(remoteRepos)
        .artifactTypeMappings(getArtifactTypeMappings());

    UrlFilePluginContext urlFilePluginContext = new UrlFilePluginContext()
        .baseDir(project.getBasedir())
        .resourceClassLoader(mavenProjectClassLoader)
        .pluginContextOptions(new PluginContextOptions()
          .containerContext(mavenContext));
    UrlFileManager urlFileManager = new UrlFileManager(pluginManager, urlFilePluginContext);

    PluginContextOptions pluginContextOptions = new PluginContextOptions()
        .pluginManager(pluginManager)
        .urlFileManager(urlFileManager)
        .valueProviderConfig(getValueProviderConfig())
        .genericPluginConfig(getPluginConfig())
        .containerContext(mavenContext)
        .logger(new MavenSlf4jLogFacade(getLog()));

    // validate that all templates can be compiled
    HandlebarsManager handlebarsManager = new HandlebarsManager(List.of(templateDir), pluginContextOptions);
    validateFiles(templateDir, templateDir, new TemplateValidator(templateDir, handlebarsManager));

    // validate that roles reference existing templates
    validateFiles(roleDir, roleDir, new RoleTemplateFileValidator(handlebarsManager));

    // validate that no value providers are used in role files - they should be only used in environment
    validateFiles(roleDir, roleDir, new NoValueProviderInRoleValidator());

    // validate environment definition syntax
    List<Environment> environmentList = validateFiles(environmentDir, environmentDir, new ModelValidator<Environment>("Environment", new EnvironmentReader()),
        // filter environments
        resourceInfo -> {
          if (this.environments == null || this.environments.length == 0) {
            return true;
          }
          for (String environment : this.environments) {
            if (StringUtils.equals(environment, FilenameUtils.getBaseName(resourceInfo.getName()))) {
              return true;
            }
          }
          return false;
        });

    // validate version information - for each environment separately
    for (Environment environment : environmentList) {
      UrlFilePluginContext environmentUrlFilePluginContext = new UrlFilePluginContext()
          .baseDir(project.getBasedir())
          .resourceClassLoader(mavenProjectClassLoader)
          .environment(environment)
          .pluginContextOptions(new PluginContextOptions()
              .containerContext(mavenContext));
      UrlFileManager environmentUrlFileManager = new UrlFileManager(pluginManager, environmentUrlFilePluginContext);

      PluginContextOptions environmentPluginContextOptions = new PluginContextOptions()
          .pluginContextOptions(pluginContextOptions)
          .urlFileManager(environmentUrlFileManager);
      validateVersionInfo(environment, mavenProjectClasspathUrls, environmentPluginContextOptions);
    }
  }

  // ===== FILE VALIDATION =====

  private <T> List<T> validateFiles(ResourceCollection sourceDir, ResourceCollection rootSourceDir, DefinitionValidator<T> validator)
      throws MojoFailureException {
    return validateFiles(sourceDir, rootSourceDir, validator, resourceInfo -> true);
  }

  private <T> List<T> validateFiles(ResourceCollection sourceDir, ResourceCollection rootSourceDir, DefinitionValidator<T> validator,
      Predicate<ResourceInfo> resourceFilter) throws MojoFailureException {
    if (!sourceDir.exists()) {
      return List.of();
    }
    SortedSet<Resource> files = sourceDir.getResources();
    SortedSet<ResourceCollection> dirs = sourceDir.getResourceCollections();
    if (files.isEmpty() && dirs.isEmpty()) {
      return List.of();
    }

    List<T> result = new ArrayList<>();
    for (Resource file : files) {
      if (resourceFilter.test(file)) {
        result.add(validator.validate(file, getPathForLog(rootSourceDir, file)));
      }
    }
    for (ResourceCollection dir : dirs) {
      if (resourceFilter.test(dir)) {
        result.addAll(validateFiles(dir, rootSourceDir, validator, resourceFilter));
      }
    }
    return result;
  }

  @SuppressWarnings("java:S1075") // no filesystem path
  private static String getPathForLog(ResourceCollection rootSourceDir, Resource file) {
    String path = PathUtil.unifySlashes(file.getCanonicalPath());
    String rootPath = PathUtil.unifySlashes(rootSourceDir.getCanonicalPath()) + "/";
    return StringUtils.substringAfter(path, rootPath);
  }

  // ===== PLUGIN VERSION INFO VALIDATION =====

  /**
   * Validates that the CONGA maven plugin version and CONGA plugin versions match or are newer than those versions used
   * when generating the dependency artifacts.
   * @param environment Environment
   * @param mavenProjectClasspathUrls Classpath URLs of maven project
   * @param pluginContextOptions Plugin context options
   */
  private void validateVersionInfo(Environment environment, List<URL> mavenProjectClasspathUrls, PluginContextOptions pluginContextOptions)
      throws MojoExecutionException {

    // build combined classpath for dependencies defined in environment and maven project
    List<URL> classpathUrls = new ArrayList<>();
    classpathUrls.addAll(getEnvironmentClasspathUrls(environment.getDependencies(), pluginContextOptions));
    classpathUrls.addAll(mavenProjectClasspathUrls);
    ClassLoader environmentDependenciesClassLoader = ClassLoaderUtil.buildClassLoader(classpathUrls);

    // get version info from this project
    Properties currentVersionInfo = VersionInfoUtil.getVersionInfoProperties(project);

    // validate current version info against dependency version infos
    for (Properties dependencyVersionInfo : getDependencyVersionInfos(environmentDependenciesClassLoader)) {
      validateVersionInfo(currentVersionInfo, dependencyVersionInfo);
    }

  }

  private List<URL> getEnvironmentClasspathUrls(List<String> dependencyUrls, PluginContextOptions pluginContextOptions) {
    return dependencyUrls.stream()
        .map(dependencyUrl -> {
          String resolvedDependencyUrl = ClassLoaderUtil.resolveDependencyUrl(dependencyUrl, pluginContextOptions);
          try {
            return pluginContextOptions.getUrlFileManager().getFileUrlsWithDependencies(resolvedDependencyUrl);
          }
          catch (IOException ex) {
            throw new GeneratorException("Unable to resolve: " + resolvedDependencyUrl, ex);
          }
        })
        .flatMap(List::stream)
        .collect(Collectors.toList());
  }

  private void validateVersionInfo(Properties currentVersionInfo, Properties dependencyVersionInfo) throws MojoExecutionException {
    for (Object keyObject : currentVersionInfo.keySet()) {
      String key = keyObject.toString();
      String currentVersionString = currentVersionInfo.getProperty(key);
      String dependencyVersionString = dependencyVersionInfo.getProperty(key);
      if (StringUtils.isEmpty(currentVersionString) || StringUtils.isEmpty(dependencyVersionString)) {
        continue;
      }
      DefaultArtifactVersion currentVersion = new DefaultArtifactVersion(currentVersionString);
      DefaultArtifactVersion dependencyVersion = new DefaultArtifactVersion(dependencyVersionString);
      if (currentVersion.compareTo(dependencyVersion) < 0) {
        throw new MojoExecutionException("Newer CONGA maven plugin or plugin version required: " + key + ":" + dependencyVersion.toString());
      }
    }
  }

  private List<Properties> getDependencyVersionInfos(ClassLoader classLoader) throws MojoExecutionException {
    PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(classLoader);
    try {
      org.springframework.core.io.Resource[] resources = resolver.getResources(
          "classpath*:" + GeneratorOptions.CLASSPATH_PREFIX + BuildConstants.FILE_VERSION_INFO);
      return Arrays.stream(resources)
          .map(this::toProperties)
          .collect(Collectors.toList());
    }
    catch (IOException ex) {
      throw new MojoExecutionException("Unable to get classpath resources: " + ex.getMessage(), ex);
    }
  }

  private Properties toProperties(org.springframework.core.io.Resource resource) {
    try (InputStream is = resource.getInputStream()) {
      Properties props = new Properties();
      props.load(is);
      return props;
    }
    catch (IOException ex) {
      throw new IllegalArgumentException("Unable to read properties file: " + resource.toString(), ex);
    }
  }

}