aboutsummaryrefslogtreecommitdiffstats
path: root/vespa-enforcer-extensions/src/main/java/com/yahoo/vespa/maven/plugin/enforcer/EnforceDependenciesAllProjects.java
blob: 82c705c46119a3fe0739c6c3cccf1b0cd21fdfa9 (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
// 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.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
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 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);
        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 {
            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 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)
            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 = session.getAllProjects();
            if (projects.size() == 1) {
                throw new EnforcerRuleException(
                        "Only a single Maven module detected. Enforcer must be executed from root of aggregator pom.");
            }
            for (MavenProject project : projects) {
                var req = new DefaultProjectBuildingRequest(session.getProjectBuildingRequest());
                req.setProject(project);
                DependencyNode root = graphBuilder.buildDependencyGraph(req, null);
                String projectId = "%s:%s".formatted(project.getGroupId(), project.getArtifactId());
                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 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 Path resolveSpecFile(EnforcerRuleHelper helper, String specFile) {
        return Paths.get(mavenProject(helper).getBasedir() + File.separator + specFile).normalize();
    }

    private static String projectName(EnforcerRuleHelper helper) {
        MavenProject p = mavenProject(helper);
        return p.getModules().isEmpty() ? p.getName() : ".";
    }

    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 ""; }

}