aboutsummaryrefslogtreecommitdiffstats
path: root/vespa-enforcer-extensions/src/main/java/com/yahoo/vespa/maven/plugin/enforcer/AllowedDependencies.java
blob: 77f097e3b8867c59dd108d5c45c2259212a3c967 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// 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<String> 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 <prop>=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<Dependency> getDependenciesOfAllProjects() throws EnforcerRuleException {
        try {
            Pattern depIgnorePattern = Pattern.compile(
                    ignored.stream()
                            .map(s -> s.replace(".", "\\.").replace("*", ".*").replace(":", "\\:").replace('?', '.'))
                            .collect(Collectors.joining(")|(", "^(", ")$")));
            List<MavenProject> projects = getAllProjects(session, rootProjectId);
            Set<Dependency> 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<Dependency> 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<MavenProject> getAllProjects(MavenSession session, String rootProjectId) throws EnforcerRuleException {
        if (rootProjectId == null) throw new EnforcerRuleException("Missing required <rootProjectId> in <enforceDependencies> in pom.xml");

        List<MavenProject> 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<Path, MavenProject> projectsByBaseDir = allProjects
                .stream()
                .collect(Collectors.toMap(project -> project.getBasedir().toPath().normalize(), project -> project));

        var projects = new ArrayList<MavenProject>();

        var pendingProjects = new ArrayDeque<MavenProject>();
        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<Rule> loadDependencySpec(Path specFile) {
        try (Stream<String> 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<Rule> spec, Set<Dependency> dependencies) {
        var resolvedDeps = new HashSet<Dependency>();
        var resolveRules = new HashSet<Rule>();
        var unmatchedDeps = new HashSet<Dependency>();
        var unmatchedRules = new HashSet<Rule>();
        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<String>();
        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<String, Set<String>> versionsForDependency = new TreeMap<>();
        Set<Dependency> 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<String> 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<String> 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<Dependency> matchedDeps, Set<Rule> matchedRules,
                    Set<Dependency> unmatchedDeps, Set<Rule> 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 ""; }
}