com.yahoo.vespa.maven.plugin.enforcer.AllowedDependencies Maven / Gradle / Ivy
// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.maven.plugin.enforcer;
import org.apache.maven.artifact.Artifact;
import org.apache.maven.enforcer.rule.api.AbstractEnforcerRule;
import org.apache.maven.enforcer.rule.api.EnforcerRule;
import org.apache.maven.enforcer.rule.api.EnforcerRuleException;
import org.apache.maven.enforcer.rule.api.EnforcerRuleHelper;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.project.DefaultProjectBuildingRequest;
import org.apache.maven.project.MavenProject;
import org.apache.maven.shared.dependency.graph.DependencyGraphBuilder;
import org.apache.maven.shared.dependency.graph.DependencyGraphBuilderException;
import org.apache.maven.shared.dependency.graph.DependencyNode;
import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException;
import org.codehaus.plexus.component.repository.exception.ComponentLookupException;
import javax.inject.Inject;
import javax.inject.Named;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* @author bjorncs
*/
@Named("allowedDependencies")
@SuppressWarnings("deprecation")
public class AllowedDependencies extends AbstractEnforcerRule implements EnforcerRule {
private static final String WRITE_SPEC_PROP = "dependencyEnforcer.writeSpec";
private static final String GUESS_VERSION = "dependencyEnforcer.guessProperty";
@Inject private MavenProject project;
@Inject private MavenSession session;
@Inject private DependencyGraphBuilder graphBuilder;
// Injected parameters
public List ignored;
public String rootProjectId;
public String specFile;
@Override
public void execute(EnforcerRuleHelper helper) throws EnforcerRuleException {
try {
project = (MavenProject) helper.evaluate("${project}");
session = (MavenSession) helper.evaluate("${session}");
graphBuilder = helper.getComponent(DependencyGraphBuilder.class);
} catch (ExpressionEvaluationException | ComponentLookupException e) {
throw new RuntimeException(e);
}
execute();
}
public void execute() throws EnforcerRuleException {
var dependencies = getDependenciesOfAllProjects();
getLog().info("Found %d unique dependencies ".formatted(dependencies.size()));
var specFile = Paths.get(project.getBasedir() + File.separator + this.specFile).normalize();
var spec = loadDependencySpec(specFile);
var resolved = resolve(spec, dependencies);
if (System.getProperties().containsKey(WRITE_SPEC_PROP)) {
// Guess property for version by default, can be disabled with =false
var guessProperty = Optional.ofNullable(System.getProperty(GUESS_VERSION))
.map(p -> p.isEmpty() || Boolean.parseBoolean(p))
.orElse(true);
writeDependencySpec(specFile, resolved, guessProperty);
getLog().info("Updated spec file '%s'".formatted(specFile.toString()));
} else {
warnOnDuplicateVersions(resolved);
validateDependencies(resolved, session.getRequest().getPom().toPath(), project.getArtifactId());
}
getLog().info("The dependency enforcer completed successfully");
}
private static void validateDependencies(Resolved resolved, Path aggregatorPomRoot, String moduleName)
throws EnforcerRuleException {
if (!resolved.unmatchedRules().isEmpty() || !resolved.unmatchedDeps().isEmpty()) {
var errorMsg = new StringBuilder("The dependency enforcer failed:\n");
if (!resolved.unmatchedRules().isEmpty()) {
errorMsg.append("Rules not matching any dependency:\n");
resolved.unmatchedRules().forEach(r -> errorMsg.append(" - ").append(r.asString()).append('\n'));
}
if (!resolved.unmatchedDeps().isEmpty()) {
errorMsg.append("Dependencies not matching any rule:\n");
resolved.unmatchedDeps().forEach(d -> errorMsg.append(" - ").append(d.asString(null)).append('\n'));
}
throw new EnforcerRuleException(
errorMsg.append("Maven dependency validation failed. ")
.append("If this change was intentional, update the dependency spec by running:\n")
.append("$ mvn validate -D").append(WRITE_SPEC_PROP).append(" -pl :").append(moduleName)
.append(" -f ").append(aggregatorPomRoot).append("\n").toString());
}
}
private Set getDependenciesOfAllProjects() throws EnforcerRuleException {
try {
Pattern depIgnorePattern = Pattern.compile(
ignored.stream()
.map(s -> s.replace(".", "\\.").replace("*", ".*").replace(":", "\\:").replace('?', '.'))
.collect(Collectors.joining(")|(", "^(", ")$")));
List projects = getAllProjects(session, rootProjectId);
Set dependencies = new HashSet<>();
for (MavenProject project : projects) {
var req = new DefaultProjectBuildingRequest(session.getProjectBuildingRequest());
req.setProject(project);
var root = graphBuilder.buildDependencyGraph(req, null);
addDependenciesRecursive(root, dependencies, depIgnorePattern);
}
return Set.copyOf(dependencies);
} catch (DependencyGraphBuilderException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
private static void addDependenciesRecursive(DependencyNode node, Set dependencies, Pattern ignored) {
if (node.getChildren() != null) {
for (DependencyNode dep : node.getChildren()) {
Artifact a = dep.getArtifact();
Dependency dependency = Dependency.fromArtifact(a);
if (!ignored.matcher(dependency.asString(null)).matches()) {
dependencies.add(dependency);
}
addDependenciesRecursive(dep, dependencies, ignored);
}
}
}
/** Only return the projects we'd like to enforce dependencies for: the root project, its modules, their modules, etc. */
private static List getAllProjects(MavenSession session, String rootProjectId) throws EnforcerRuleException {
if (rootProjectId == null) throw new EnforcerRuleException("Missing required in in pom.xml");
List allProjects = session.getAllProjects();
if (allProjects.size() == 1) {
throw new EnforcerRuleException(
"Only a single Maven module detected. Enforcer must be executed from root of aggregator pom.");
}
MavenProject rootProject = allProjects
.stream()
.filter(project -> rootProjectId.equals(projectIdOf(project)))
.findAny()
.orElseThrow(() -> new EnforcerRuleException("Root project not found: " + rootProjectId));
Map projectsByBaseDir = allProjects
.stream()
.collect(Collectors.toMap(project -> project.getBasedir().toPath().normalize(), project -> project));
var projects = new ArrayList();
var pendingProjects = new ArrayDeque();
pendingProjects.add(rootProject);
while (!pendingProjects.isEmpty()) {
MavenProject project = pendingProjects.pop();
projects.add(project);
for (var module : project.getModules()) {
// Assumption: The module is a relative path to a project base directory.
Path moduleBaseDir = project.getBasedir().toPath().resolve(module).normalize();
MavenProject moduleProject = projectsByBaseDir.get(moduleBaseDir);
if (moduleProject == null)
throw new EnforcerRuleException("Failed to find module '" + module + "' in project " + project.getBasedir());
pendingProjects.add(moduleProject);
}
}
projects.sort(Comparator.comparing(AllowedDependencies::projectIdOf));
return projects;
}
private List loadDependencySpec(Path specFile) {
try (Stream s = Files.lines(specFile)) {
return s.map(String::trim)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.map(Rule::fromString)
.toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private Resolved resolve(List spec, Set dependencies) {
var resolvedDeps = new HashSet();
var resolveRules = new HashSet();
var unmatchedDeps = new HashSet();
var unmatchedRules = new HashSet();
for (var rule : spec) {
var requiredDependency = rule.resolveToDependency(project.getProperties());
if (dependencies.contains(requiredDependency)) {
resolvedDeps.add(requiredDependency);
resolveRules.add(rule);
} else {
unmatchedRules.add(rule);
}
}
for (var dependency : dependencies) {
if (!resolvedDeps.contains(dependency)) {
unmatchedDeps.add(dependency);
}
}
return new Resolved(resolvedDeps, resolveRules, unmatchedDeps, unmatchedRules);
}
void writeDependencySpec(Path specFile, Resolved resolved, boolean guessVersion) {
var content = new TreeSet();
resolved.matchedRules().forEach(r -> content.add(r.asString()));
resolved.unmatchedDeps().forEach(d -> content.add(d.asString(guessVersion ? project.getProperties() : null)));
try (var out = Files.newBufferedWriter(specFile)) {
out.write("# Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.\n\n");
for (var line : content) {
out.write(line); out.write('\n');
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private void warnOnDuplicateVersions(Resolved resolved) {
Map> versionsForDependency = new TreeMap<>();
Set allDeps = new HashSet<>(resolved.matchedDeps());
allDeps.addAll(resolved.unmatchedDeps());
for (Dependency d : allDeps) {
String id = "%s:%s".formatted(d.groupId(), d.artifactId());
versionsForDependency.computeIfAbsent(id, __ -> new TreeSet<>()).add(d.version());
}
versionsForDependency.forEach((dependency, versions) -> {
if (versions.size() > 1) {
getLog().warn("'%s' has multiple versions %s".formatted(dependency, versions));
}
});
}
private static String projectIdOf(MavenProject project) { return "%s:%s".formatted(project.getGroupId(), project.getArtifactId()); }
private record Rule(String groupId, String artifactId, String version, Optional classifier){
static final Pattern PROPERTY_PATTERN = Pattern.compile("\\$\\{(.+?)}");
static Rule fromString(String s) {
String[] splits = s.split(":");
return splits.length == 3
? new Rule(splits[0], splits[1], splits[2], Optional.empty())
: new Rule(splits[0], splits[1], splits[2], Optional.of(splits[3]));
}
Dependency resolveToDependency(Properties props) {
// Replace expressions on form ${property} in 'version' field with value from properties
var matcher = PROPERTY_PATTERN.matcher(version);
var resolvedVersion = version;
while (matcher.find()) {
String property = matcher.group(1);
String value = props.getProperty(property);
if (value == null) throw new IllegalArgumentException("Missing property: " + property);
resolvedVersion = version.replace(matcher.group(), value);
}
return new Dependency(groupId, artifactId, resolvedVersion, classifier);
}
String asString() {
var b = new StringBuilder(groupId).append(':').append(artifactId).append(':').append(version);
classifier.ifPresent(c -> b.append(':').append(c));
return b.toString();
}
}
record Dependency(String groupId, String artifactId, String version, Optional classifier) {
static Dependency fromArtifact(Artifact a) {
return new Dependency(
a.getGroupId(), a.getArtifactId(), a.getVersion(), Optional.ofNullable(a.getClassifier()));
}
String asString(Properties props) {
String versionStr = version;
if (props != null) {
// Guess property name if properties are provided
var matchingProps = props.entrySet().stream()
.filter(e -> e.getValue().equals(version))
.map(v -> "${%s}".formatted(v.getKey()))
.collect(Collectors.joining("|"));
if (!matchingProps.isEmpty()) versionStr = matchingProps;
}
var b = new StringBuilder(groupId).append(':').append(artifactId).append(':').append(versionStr);
classifier.ifPresent(c -> b.append(':').append(c));
return b.toString();
}
}
record Resolved(Set matchedDeps, Set matchedRules,
Set unmatchedDeps, Set unmatchedRules) {}
// Mark rule as not cachable
@Override public boolean isCacheable() { return false; }
@Override public boolean isResultValid(EnforcerRule r) { return false; }
@Override public String getCacheId() { return ""; }
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy