aboutsummaryrefslogtreecommitdiffstats
path: root/vespa-enforcer-extensions/src/main/java/com/yahoo/vespa/maven/plugin/enforcer/EnforceDependenciesAllProjects.java
blob: 3787d84e4be7af6828a71ec6ab89cc4ac0467314 (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
311
312
313
314
315
316
317
318
// Copyright Yahoo. 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.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.plugin.logging.Log;
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 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.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
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
 */
public class EnforceDependenciesAllProjects implements EnforcerRule {

    private static final String WRITE_SPEC_PROP = "dependencyEnforcer.writeSpec";
    private static final String NON_TEST_HEADER = "#[non-test]";
    private static final String TEST_ONLY_HEADER = "#[test-only]";

    private String rootProjectId;
    private String specFile;
    private List<String> ignored = List.of();
    private List<String> testUtilProjects = List.of();

    @Override
    public void execute(EnforcerRuleHelper helper) throws EnforcerRuleException {
        Log log = helper.getLog();
        Dependencies deps = getDependenciesOfAllProjects(helper, ignored, testUtilProjects, rootProjectId);
        log.info("Found %d unique dependencies (%d non-test, %d test only)".formatted(
                deps.nonTest().size() + deps.testOnly().size(), deps.nonTest().size(), deps.testOnly().size()));
        Path specFile = resolveSpecFile(helper, this.specFile);
        if (System.getProperties().containsKey(WRITE_SPEC_PROP)) {
            writeDependencySpec(specFile, deps);
            log.info("Updated spec file '%s'".formatted(specFile.toString()));
        } else {
            warnOnDuplicateVersions(log, deps);
            validateDependencies(deps, specFile, aggregatorPomRoot(helper), projectName(helper));
        }
        log.info("The dependency enforcer completed successfully");
    }

    // Config injection for rule configuration. Method names must match config XML elements.
    @SuppressWarnings("unused") public void setRootProjectId(String l) { this.rootProjectId = l; }
    @SuppressWarnings("unused") public String getRootProjectId() { return rootProjectId; }
    @SuppressWarnings("unused") public void setSpecFile(String f) { this.specFile = f; }
    @SuppressWarnings("unused") public String getSpecFile() { return specFile; }
    @SuppressWarnings("unused") public void setIgnored(List<String> l) { this.ignored = l; }
    @SuppressWarnings("unused") public List<String> getIgnored() { return ignored; }
    @SuppressWarnings("unused") public void setTestUtilProjects(List<String> l) { this.testUtilProjects = l; }
    @SuppressWarnings("unused") public List<String> getTestUtilProjects() { return testUtilProjects; }

    record Dependency(String groupId, String artifactId, String version, Optional<String> classifier)
            implements Comparable<Dependency> {
        static Dependency fromArtifact(Artifact a) {
            return new Dependency(
                    a.getGroupId(), a.getArtifactId(), a.getVersion(), Optional.ofNullable(a.getClassifier()));
        }

        static Dependency fromString(String s) {
            String[] splits = s.split(":");
            return splits.length == 3
                    ? new Dependency(splits[0], splits[1], splits[2], Optional.empty())
                    : new Dependency(splits[0], splits[1], splits[2], Optional.of(splits[3]));
        }

        String asString() {
            var b = new StringBuilder(groupId).append(':').append(artifactId).append(':').append(version);
            classifier.ifPresent(c -> b.append(':').append(c));
            return b.toString();
        }

        static final Comparator<Dependency> COMPARATOR = Comparator.comparing(Dependency::groupId)
                .thenComparing(Dependency::artifactId).thenComparing(Dependency::version)
                .thenComparing(d -> d.classifier().orElse(""));
        @Override public int compareTo(Dependency o) { return COMPARATOR.compare(this, o); }
    }

    record Dependencies(SortedSet<Dependency> nonTest, SortedSet<Dependency> testOnly) {}

    static void validateDependencies(Dependencies dependencies, Path specFile, Path aggregatorPomRoot,
                                     String moduleName)
            throws EnforcerRuleException {
        Dependencies allowedDependencies = loadDependencySpec(specFile);
        if (!allowedDependencies.equals(dependencies)) {
            StringBuilder errorMsg = new StringBuilder("The dependency enforcer failed:\n");
            generateDiff(errorMsg, "non-test", dependencies.nonTest(), allowedDependencies.nonTest());
            generateDiff(errorMsg, "test-only", dependencies.testOnly(), allowedDependencies.testOnly());
            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());
        }
    }

    static void generateDiff(
            StringBuilder errorMsg, String label, SortedSet<Dependency> actual, SortedSet<Dependency> expected) {
        SortedSet<Dependency> forbidden = new TreeSet<>(actual);
        forbidden.removeAll(expected);
        SortedSet<Dependency> removed = new TreeSet<>(expected);
        removed.removeAll(actual);
        if (!forbidden.isEmpty()) {
            errorMsg.append("Forbidden ").append(label).append(" dependencies:\n");
            forbidden.forEach(d -> errorMsg.append(" - ").append(d.asString()).append('\n'));
        }
        if (!removed.isEmpty()) {
            errorMsg.append("Removed ").append(label).append(" dependencies:\n");
            removed.forEach(d -> errorMsg.append(" - ").append(d.asString()).append('\n'));
        }
    }

    private static Dependencies getDependenciesOfAllProjects(EnforcerRuleHelper helper, List<String> ignored,
                                                             List<String> testUtilProjects, String rootProjectId)
            throws EnforcerRuleException {
        try {
            Pattern depIgnorePattern = Pattern.compile(
                    ignored.stream()
                            .map(s -> s.replace(".", "\\.").replace("*", ".*").replace(":", "\\:").replace('?', '.'))
                            .collect(Collectors.joining(")|(", "^(", ")$")));
            Pattern projectIgnorePattern = Pattern.compile(
                    testUtilProjects.stream()
                            .map(s -> s.replace(".", "\\.").replace("*", ".*").replace(":", "\\:").replace('?', '.'))
                            .collect(Collectors.joining(")|(", "^(", ")$")));
            SortedSet<Dependency> nonTestDeps = new TreeSet<>();
            SortedSet<Dependency> testDeps = new TreeSet<>();
            MavenSession session = mavenSession(helper);
            var graphBuilder = helper.getComponent(DependencyGraphBuilder.class);
            List<MavenProject> projects = getAllProjects(session, rootProjectId);
            for (MavenProject project : projects) {
                var req = new DefaultProjectBuildingRequest(session.getProjectBuildingRequest());
                req.setProject(project);
                DependencyNode root = graphBuilder.buildDependencyGraph(req, null);
                String projectId = projectIdOf(project);
                boolean overrideToTest = projectIgnorePattern.matcher(projectId).matches();
                if (overrideToTest) helper.getLog().info("Treating dependencies of '%s' as 'test'".formatted(projectId));
                addDependenciesRecursive(root, nonTestDeps, testDeps, depIgnorePattern, overrideToTest);
            }
            testDeps.removeAll(nonTestDeps);
            return new Dependencies(nonTestDeps, testDeps);
        } catch (DependencyGraphBuilderException | ComponentLookupException e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }

    private static String projectIdOf(MavenProject project) { return "%s:%s".formatted(project.getGroupId(), project.getArtifactId()); }

    /** 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(EnforceDependenciesAllProjects::projectIdOf));
        return projects;
    }

    private static void addDependenciesRecursive(
            DependencyNode node, Set<Dependency> nonTestDeps, Set<Dependency> testDeps, Pattern ignored,
            boolean overrideToTest) {
        if (node.getChildren() != null) {
            for (DependencyNode dep : node.getChildren()) {
                Artifact a = dep.getArtifact();
                Dependency dependency = Dependency.fromArtifact(a);
                if (!ignored.matcher(dependency.asString()).matches()) {
                    if (a.getScope().equals("test") || overrideToTest) {
                        testDeps.add(dependency);
                    } else {
                        nonTestDeps.add(dependency);
                    }
                }
                addDependenciesRecursive(dep, nonTestDeps, testDeps, ignored, overrideToTest);
            }
        }
    }

    private static void warnOnDuplicateVersions(Log log, Dependencies deps) {
        Map<String, Set<String>> versionsForDependency = new TreeMap<>();
        Set<Dependency> allDeps = new TreeSet<>(deps.nonTest());
        allDeps.addAll(deps.testOnly());
        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) {
                log.warn("'%s' has multiple versions %s".formatted(dependency, versions));
            }
        });
    }

    private static Path resolveSpecFile(EnforcerRuleHelper helper, String specFile) {
        return Paths.get(mavenProject(helper).getBasedir() + File.separator + specFile).normalize();
    }

    private static String projectName(EnforcerRuleHelper helper) { return mavenProject(helper).getArtifactId(); }

    private static Path aggregatorPomRoot(EnforcerRuleHelper helper) {
        return mavenSession(helper).getRequest().getPom().toPath();
    }

    private static MavenProject mavenProject(EnforcerRuleHelper helper) {
        try {
            return (MavenProject) helper.evaluate("${project}");
        } catch (ExpressionEvaluationException e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }

    private static MavenSession mavenSession(EnforcerRuleHelper helper) {
        try {
            return (MavenSession) helper.evaluate("${session}");
        } catch (ExpressionEvaluationException e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }

    static void writeDependencySpec(Path specFile, Dependencies dependencies) {
        try (var out = Files.newBufferedWriter(specFile)) {
            out.write("# Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.\n\n");
            out.write(NON_TEST_HEADER); out.write('\n');
            out.write("# Contains dependencies that are not used exclusively in 'test' scope\n");
            for (Dependency d : dependencies.nonTest()) {
                out.write(d.asString()); out.write('\n');
            }
            out.write("\n"); out.write(TEST_ONLY_HEADER); out.write('\n');
            out.write("# Contains dependencies that are used exclusively in 'test' scope\n");
            for (Dependency d : dependencies.testOnly()) {
                out.write(d.asString()); out.write('\n');
            }
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private static Dependencies loadDependencySpec(Path specFile) {
        try {
            List<String> lines;
            try (Stream<String> s = Files.lines(specFile)) {
                lines = s.map(String::trim).filter(l -> !l.isEmpty()).toList();
            }
            SortedSet<Dependency> nonTest = parseDependencies(lines.stream().takeWhile(l -> !l.equals(TEST_ONLY_HEADER)));
            SortedSet<Dependency> testOnly = parseDependencies(lines.stream().dropWhile(l -> !l.equals(TEST_ONLY_HEADER)));
            return new Dependencies(nonTest, testOnly);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private static SortedSet<Dependency> parseDependencies(Stream<String> lines) {
        return lines.filter(l -> !l.startsWith("#")).map(Dependency::fromString)
                .collect(Collectors.toCollection(TreeSet::new));
    }

    // Mark rule as not cachable
    @Override public boolean isCacheable() { return false; }
    @Override public boolean isResultValid(EnforcerRule r) { return false; }
    @Override public String getCacheId() { return ""; }

}