Skip to content

Commit c300d9f

Browse files
authored
Make embedded Maven output concise and reproducible (#1019)
Make embedded Maven execution concise and reproducible
1 parent 1987e3c commit c300d9f

10 files changed

Lines changed: 470 additions & 22 deletions

File tree

native-maven-plugin/build-plugins/src/main/java/org/graalvm/build/maven/GeneratePluginDescriptor.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@
1818
import org.gradle.api.file.DirectoryProperty;
1919
import org.gradle.api.tasks.CacheableTask;
2020
import org.gradle.api.tasks.Internal;
21+
import org.gradle.api.tasks.LocalState;
2122
import org.gradle.process.JavaExecSpec;
2223

2324
import java.io.File;
2425
import java.util.Arrays;
26+
import java.util.List;
2527

28+
/**
29+
* Generates the plugin descriptor with a task-local Maven repository. §E2E-functional-tests.4
30+
*/
2631
@CacheableTask
2732
public abstract class GeneratePluginDescriptor extends MavenTask {
2833

@@ -32,8 +37,12 @@ public abstract class GeneratePluginDescriptor extends MavenTask {
3237
@Internal
3338
public abstract DirectoryProperty getLocalRepository();
3439

40+
@LocalState
41+
public abstract DirectoryProperty getMavenLocalRepository();
42+
3543
public GeneratePluginDescriptor() {
3644
getArguments().set(Arrays.asList("-q", "org.apache.maven.plugins:maven-plugin-plugin:3.6.1:descriptor"));
45+
getMavenLocalRepository().convention(getProject().getLayout().getBuildDirectory().dir("maven-local/" + getName()));
3746
}
3847

3948
@Override
@@ -42,6 +51,14 @@ protected void prepareSpec(JavaExecSpec spec) {
4251
spec.systemProperty("seed.repo.uri", getLocalRepository().get().getAsFile().toURI().toASCIIString());
4352
}
4453

54+
@Override
55+
protected void prepareArguments(List<String> arguments) {
56+
File repository = getMavenLocalRepository().getAsFile().get();
57+
getFileSystemOperations().delete(spec -> spec.delete(
58+
getProject().fileTree(repository, files -> files.include("**/*.lastUpdated"))));
59+
arguments.add("-Dmaven.repo.local=" + repository.getAbsolutePath());
60+
}
61+
4562
@Override
4663
protected void extractOutput(File tmpDir, File outputDirectory) {
4764
getFileSystemOperations().copy(spec -> {
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
* Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
3+
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
*
5+
* The Universal Permissive License (UPL), Version 1.0
6+
*
7+
* Subject to the condition set forth below, permission is hereby granted to any
8+
* person obtaining a copy of this software, associated documentation and/or
9+
* data (collectively the "Software"), free of charge and under any and all
10+
* copyright rights in the Software, and in any patent rights owned or freely
11+
* licensable by each licensor hereunder covering either (i) the unmodified
12+
* Software as contributed to or provided by such licensor, or (ii) the Larger
13+
* Works (as defined below), to deal in both
14+
*
15+
* (a) the Software, and
16+
*
17+
* (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if
18+
* one is included with the Software each a "Larger Work" to which the Software
19+
* is contributed by such licensors),
20+
*
21+
* without restriction, including without limitation the rights to copy, create
22+
* derivative works of, display, perform, and distribute the Software and the
23+
* Larger Work(s), and to make, use, sell, offer for sale, import, export, have
24+
* made, and have sold the Software and the Larger Work(s), and to sublicense the
25+
* foregoing rights on either these or other terms.
26+
*
27+
* This license is subject to the following condition:
28+
*
29+
* The above copyright notice and either this complete permission notice or at
30+
* a minimum a reference to the UPL must be included in all copies or
31+
* substantial portions of the Software.
32+
*
33+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
34+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
35+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
36+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
37+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
38+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
39+
* SOFTWARE.
40+
*/
41+
package org.graalvm.build.maven;
42+
43+
import java.io.ByteArrayOutputStream;
44+
import java.io.OutputStream;
45+
import java.net.URI;
46+
import java.net.URISyntaxException;
47+
import java.nio.charset.StandardCharsets;
48+
import java.util.regex.Matcher;
49+
import java.util.regex.Pattern;
50+
51+
/**
52+
* Bounds embedded Maven output and renders one safe actionable failure. §E2E-functional-tests.4
53+
*/
54+
final class MavenOutput extends OutputStream {
55+
private static final int MAXIMUM_BYTES = 4 * 1024 * 1024;
56+
private static final Pattern ANSI_ESCAPE = Pattern.compile("\\x1B(?:\\[[0-?]*[ -/]*[@-~]|\\][^\\x07]*(?:\\x07|\\x1B\\\\))");
57+
private static final Pattern RESOLUTION_FAILURE = Pattern.compile(
58+
"Could not (?:transfer|find) artifact ([^\\s]+) (?:from/to|in) ([^\\s(]+) \\(([^)]+)\\)",
59+
Pattern.CASE_INSENSITIVE);
60+
private static final Pattern CACHED_NOT_FOUND = Pattern.compile(
61+
"([^\\s]+) was not found in ([^\\s]+) during a previous attempt",
62+
Pattern.CASE_INSENSITIVE);
63+
private static final Pattern SLF4J_ERROR = Pattern.compile("^\\[[^]]+]\\s+ERROR\\s+\\S+\\s+-\\s*(.*)$");
64+
private static final Pattern URL = Pattern.compile("[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s)]+(?:\\)[^\\s]*)?");
65+
private static final String DIAGNOSTIC_HINT = "Rerun with --info or --debug for Maven diagnostics.";
66+
67+
private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
68+
private boolean truncated;
69+
70+
@Override
71+
public synchronized void write(int value) {
72+
if (bytes.size() < MAXIMUM_BYTES) {
73+
bytes.write(value);
74+
} else {
75+
truncated = true;
76+
}
77+
}
78+
79+
@Override
80+
public synchronized void write(byte[] data, int offset, int length) {
81+
int remaining = MAXIMUM_BYTES - bytes.size();
82+
if (remaining > 0) {
83+
bytes.write(data, offset, Math.min(length, remaining));
84+
}
85+
if (length > remaining) {
86+
truncated = true;
87+
}
88+
}
89+
90+
synchronized String contents() {
91+
String output = bytes.toString(StandardCharsets.UTF_8);
92+
if (truncated) {
93+
return output + System.lineSeparator() + "[embedded Maven output truncated]";
94+
}
95+
return output;
96+
}
97+
98+
static String failureMessage(String diagnostics) {
99+
String plainOutput = ANSI_ESCAPE.matcher(diagnostics).replaceAll("");
100+
Matcher resolution = RESOLUTION_FAILURE.matcher(plainOutput);
101+
if (resolution.find()) {
102+
String artifact = resolution.group(1);
103+
String repository = resolution.group(2);
104+
String url = sanitizeUrl(resolution.group(3));
105+
String location = url == null ? repository : repository + " (" + url + ")";
106+
return "Embedded Maven could not resolve artifact " + artifact + " from repository " + location + ". " + DIAGNOSTIC_HINT;
107+
}
108+
Matcher cachedNotFound = CACHED_NOT_FOUND.matcher(plainOutput);
109+
if (cachedNotFound.find()) {
110+
String artifact = cachedNotFound.group(1);
111+
String repository = sanitizeUrl(cachedNotFound.group(2));
112+
String location = repository == null ? "[repository URL omitted]" : repository;
113+
return "Embedded Maven could not resolve artifact " + artifact + " from repository " + location + ". " + DIAGNOSTIC_HINT;
114+
}
115+
String detail = finalMeaningfulError(plainOutput);
116+
return "Embedded Maven failed: " + detail + ". " + DIAGNOSTIC_HINT;
117+
}
118+
119+
private static String finalMeaningfulError(String output) {
120+
String[] lines = output.split("\\R");
121+
for (int index = lines.length - 1; index >= 0; index--) {
122+
String detail = errorDetail(lines[index].trim());
123+
if (detail == null) {
124+
continue;
125+
}
126+
if (!detail.isEmpty() && !detail.startsWith("-> [Help ") && !detail.startsWith("[Help ")
127+
&& !detail.startsWith("For more information")) {
128+
return stripTrailingPeriod(sanitizeUrls(detail));
129+
}
130+
}
131+
return "Maven exited with a non-zero status";
132+
}
133+
134+
private static String errorDetail(String line) {
135+
if (line.startsWith("[ERROR]")) {
136+
return line.substring("[ERROR]".length()).trim();
137+
}
138+
Matcher matcher = SLF4J_ERROR.matcher(line);
139+
return matcher.matches() ? matcher.group(1).trim() : null;
140+
}
141+
142+
private static String sanitizeUrls(String text) {
143+
Matcher matcher = URL.matcher(text);
144+
StringBuffer sanitized = new StringBuffer();
145+
while (matcher.find()) {
146+
String replacement = sanitizeUrl(matcher.group());
147+
matcher.appendReplacement(sanitized, Matcher.quoteReplacement(replacement == null ? "[repository URL omitted]" : replacement));
148+
}
149+
matcher.appendTail(sanitized);
150+
return sanitized.toString();
151+
}
152+
153+
private static String sanitizeUrl(String value) {
154+
try {
155+
URI uri = new URI(value);
156+
if (uri.getScheme() == null) {
157+
return null;
158+
}
159+
if (uri.isOpaque()) {
160+
return uri.getScheme() + ":";
161+
}
162+
return new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), uri.getPath(), null, null).toASCIIString();
163+
} catch (URISyntaxException ex) {
164+
return null;
165+
}
166+
}
167+
168+
private static String stripTrailingPeriod(String value) {
169+
return value.endsWith(".") ? value.substring(0, value.length() - 1) : value;
170+
}
171+
}

native-maven-plugin/build-plugins/src/main/java/org/graalvm/build/maven/MavenTask.java

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package org.graalvm.build.maven;
1717

1818
import org.gradle.api.DefaultTask;
19+
import org.gradle.api.GradleException;
1920
import org.gradle.api.file.ConfigurableFileCollection;
2021
import org.gradle.api.file.DirectoryProperty;
2122
import org.gradle.api.file.FileSystemOperations;
@@ -28,11 +29,14 @@
2829
import org.gradle.api.tasks.InputDirectory;
2930
import org.gradle.api.tasks.InputFile;
3031
import org.gradle.api.tasks.InputFiles;
32+
import org.gradle.api.tasks.Nested;
3133
import org.gradle.api.tasks.OutputDirectory;
3234
import org.gradle.api.tasks.PathSensitive;
3335
import org.gradle.api.tasks.PathSensitivity;
3436
import org.gradle.api.tasks.TaskAction;
37+
import org.gradle.jvm.toolchain.JavaLauncher;
3538
import org.gradle.process.ExecOperations;
39+
import org.gradle.process.ExecResult;
3640
import org.gradle.process.JavaExecSpec;
3741

3842
import javax.inject.Inject;
@@ -42,7 +46,7 @@
4246
import java.util.List;
4347

4448
/**
45-
* Runs embedded Maven with explicit offline and snapshot-update policy. §E2E-functional-tests.4
49+
* Runs embedded Maven with concise diagnostics and an explicit supported launcher. §E2E-functional-tests.4
4650
*/
4751
@CacheableTask
4852
public abstract class MavenTask extends DefaultTask {
@@ -74,15 +78,14 @@ public abstract class MavenTask extends DefaultTask {
7478
@Input
7579
public abstract Property<Boolean> getOffline();
7680

77-
@Input
78-
public abstract Property<Boolean> getUpdateSnapshots();
81+
@Nested
82+
public abstract Property<JavaLauncher> getJavaLauncher();
7983

8084
@OutputDirectory
8185
public abstract DirectoryProperty getOutputDirectory();
8286

8387
public MavenTask() {
8488
getOffline().convention(false);
85-
getUpdateSnapshots().convention(false);
8689
}
8790

8891
protected void extractOutput(File tmpDir, File outputDirectory) {
@@ -101,16 +104,23 @@ protected void executeMaven() {
101104
File settingsFile = getSettingsFile().getAsFile().get();
102105
File outputDirectory = getOutputDirectory().getAsFile().get();
103106
File projectdir = getProjectDirectory().getAsFile().get();
104-
getExecOperations().javaexec(spec -> {
107+
MavenOutput output = new MavenOutput();
108+
ExecResult result = getExecOperations().javaexec(spec -> {
105109
spec.setClasspath(getMavenEmbedderClasspath());
106110
spec.getMainClass().set("org.apache.maven.cli.MavenCli");
111+
spec.setExecutable(getJavaLauncher().get().getExecutablePath().getAsFile());
107112
spec.systemProperty("maven.multiModuleProjectDirectory", projectdir.getAbsolutePath());
108113
spec.systemProperty("org.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener", "warn");
114+
spec.setStandardOutput(output);
115+
spec.setErrorOutput(output);
116+
spec.setIgnoreExitValue(true);
109117
prepareSpec(spec);
110118
List<String> arguments = new ArrayList<>();
111-
arguments.add("--errors");
112-
if (getUpdateSnapshots().get()) {
113-
arguments.add("-U");
119+
if (getLogger().isInfoEnabled()) {
120+
arguments.add("--errors");
121+
}
122+
if (getLogger().isDebugEnabled()) {
123+
arguments.add("--debug");
114124
}
115125
if (getOffline().get()) {
116126
arguments.add("--offline");
@@ -122,8 +132,15 @@ protected void executeMaven() {
122132
arguments.addAll(getArguments().get());
123133
prepareArguments(arguments);
124134
spec.args(arguments);
125-
getLogger().lifecycle("Invoking Maven with arguments " + arguments);
135+
getLogger().info("Invoking Maven with arguments {}", arguments);
126136
});
137+
String diagnostics = output.contents();
138+
if (getLogger().isInfoEnabled() && !diagnostics.isBlank()) {
139+
getLogger().info("Embedded Maven output:\n{}", diagnostics);
140+
}
141+
if (result.getExitValue() != 0) {
142+
throw new GradleException(MavenOutput.failureMessage(diagnostics));
143+
}
127144
extractOutput(projectdir, outputDirectory);
128145
}
129146

native-maven-plugin/build-plugins/src/main/java/org/graalvm/build/maven/SeedMavenRepository.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ String currentInputKey() throws IOException {
9191
Map<String, String> values = new LinkedHashMap<>(getSeedProperties().get());
9292
values.put("arguments", String.join("\n", getArguments().get()));
9393
values.put("offline", getOffline().get().toString());
94-
values.put("updateSnapshots", getUpdateSnapshots().get().toString());
94+
values.put("javaLauncher.languageVersion", getJavaLauncher().get().getMetadata().getLanguageVersion().toString());
95+
values.put("javaLauncher.runtimeVersion", getJavaLauncher().get().getMetadata().getJavaRuntimeVersion());
96+
values.put("javaLauncher.vendor", getJavaLauncher().get().getMetadata().getVendor());
9597
Map<String, Path> files = new LinkedHashMap<>();
9698
files.put("projectDirectory", getProjectDirectory().getAsFile().get().toPath());
9799
files.put("settingsFile", getSettingsFile().getAsFile().get().toPath());

native-maven-plugin/build-plugins/src/main/kotlin/org.graalvm.build.maven-embedder.gradle.kts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,23 @@
3939
* SOFTWARE.
4040
*/
4141

42+
import org.graalvm.build.maven.MavenTask
43+
import org.gradle.jvm.toolchain.JavaLanguageVersion
44+
import org.gradle.jvm.toolchain.JavaToolchainService
45+
import org.gradle.kotlin.dsl.getByType
46+
import org.gradle.kotlin.dsl.withType
47+
4248
// Shared maintainer-facing build configurations are convention-plugin behavior. §root/FS-build-infrastructure.2.1
4349
val mavenEmbedder = configurations.create("mavenEmbedder")
4450
val testFixturesImplementation = configurations.getByName("testFixturesImplementation")
4551

4652
testFixturesImplementation.extendsFrom(mavenEmbedder)
53+
54+
// Repository-internal Maven runs on the supported Java floor independently of the Gradle daemon. §E2E-functional-tests.4
55+
val embeddedMavenLauncher = extensions.getByType<JavaToolchainService>().launcherFor {
56+
languageVersion.set(JavaLanguageVersion.of(17))
57+
}
58+
59+
tasks.withType<MavenTask>().configureEach {
60+
javaLauncher.set(embeddedMavenLauncher)
61+
}

0 commit comments

Comments
 (0)