diff --git a/core/builder/src/main/java/io/quarkus/builder/Json.java b/core/builder/src/main/java/io/quarkus/builder/Json.java index 1973e3a0411c1..57a55a3077389 100644 --- a/core/builder/src/main/java/io/quarkus/builder/Json.java +++ b/core/builder/src/main/java/io/quarkus/builder/Json.java @@ -11,6 +11,15 @@ import java.util.Map.Entry; import java.util.Objects; +import io.quarkus.builder.json.JsonArray; +import io.quarkus.builder.json.JsonBoolean; +import io.quarkus.builder.json.JsonInteger; +import io.quarkus.builder.json.JsonMember; +import io.quarkus.builder.json.JsonMultiValue; +import io.quarkus.builder.json.JsonObject; +import io.quarkus.builder.json.JsonString; +import io.quarkus.builder.json.JsonValue; + /** * A simple JSON string generator. */ @@ -48,7 +57,7 @@ private Json() { * @return the new JSON array builder, empty builders are not ignored */ public static JsonArrayBuilder array() { - return new JsonArrayBuilder(false); + return new JsonArrayBuilder(false, false); } /** @@ -56,15 +65,15 @@ public static JsonArrayBuilder array() { * @return the new JSON array builder * @see JsonBuilder#ignoreEmptyBuilders */ - static JsonArrayBuilder array(boolean ignoreEmptyBuilders) { - return new JsonArrayBuilder(ignoreEmptyBuilders); + public static JsonArrayBuilder array(boolean ignoreEmptyBuilders, boolean skipEscapeCharacters) { + return new JsonArrayBuilder(ignoreEmptyBuilders, skipEscapeCharacters); } /** * @return the new JSON object builder, empty builders are not ignored */ public static JsonObjectBuilder object() { - return new JsonObjectBuilder(false); + return new JsonObjectBuilder(false, false); } /** @@ -72,20 +81,30 @@ public static JsonObjectBuilder object() { * @return the new JSON object builder * @see JsonBuilder#ignoreEmptyBuilders */ - static JsonObjectBuilder object(boolean ignoreEmptyBuilders) { - return new JsonObjectBuilder(ignoreEmptyBuilders); + public static JsonObjectBuilder object(boolean ignoreEmptyBuilders, boolean skipEscapeCharacters) { + return new JsonObjectBuilder(ignoreEmptyBuilders, skipEscapeCharacters); } - abstract static class JsonBuilder { + public abstract static class JsonBuilder { - protected boolean ignoreEmptyBuilders = false; + protected final boolean ignoreEmptyBuilders; + /** + * Skips escaping characters in string values. + * This option should be enabled when transforming JSON input, + * whose string values are already escaped. + * In situations like this, the option avoids escaping characters + * that are already escaped. + */ + protected final boolean skipEscapeCharacters; + protected JsonTransform transform; /** * @param ignoreEmptyBuilders If set to true all empty builders added to this builder will be ignored during * {@link #build()} */ - JsonBuilder(boolean ignoreEmptyBuilders) { + JsonBuilder(boolean ignoreEmptyBuilders, boolean skipEscapeCharacters) { this.ignoreEmptyBuilders = ignoreEmptyBuilders; + this.skipEscapeCharacters = skipEscapeCharacters; } /** @@ -130,6 +149,16 @@ protected boolean isValuesEmpty(Collection values) { protected abstract T self(); + abstract void add(JsonValue element); + + void setTransform(JsonTransform transform) { + this.transform = transform; + } + + public void transform(JsonMultiValue value, JsonTransform transform) { + final ResolvedTransform resolved = new ResolvedTransform(this, transform); + value.forEach(resolved); + } } /** @@ -139,8 +168,8 @@ public static class JsonArrayBuilder extends JsonBuilder { private final List values; - private JsonArrayBuilder(boolean ignoreEmptyBuilders) { - super(ignoreEmptyBuilders); + private JsonArrayBuilder(boolean ignoreEmptyBuilders, boolean skipEscapeCharacters) { + super(ignoreEmptyBuilders, skipEscapeCharacters); this.values = new ArrayList(); } @@ -209,7 +238,7 @@ public void appendTo(Appendable appendable) throws IOException { if (++idx > 1) { appendable.append(ENTRY_SEPARATOR); } - appendValue(appendable, value); + appendValue(appendable, value, skipEscapeCharacters); } appendable.append(ARRAY_END); } @@ -219,6 +248,32 @@ protected JsonArrayBuilder self() { return this; } + @Override + void add(JsonValue element) { + if (element instanceof JsonString) { + add(((JsonString) element).value()); + } else if (element instanceof JsonInteger) { + final long longValue = ((JsonInteger) element).longValue(); + final int intValue = (int) longValue; + if (longValue == intValue) { + add(intValue); + } else { + add(longValue); + } + } else if (element instanceof JsonBoolean) { + add(((JsonBoolean) element).value()); + } else if (element instanceof JsonArray) { + final JsonArrayBuilder arrayBuilder = Json.array(ignoreEmptyBuilders, skipEscapeCharacters); + arrayBuilder.transform((JsonArray) element, transform); + add(arrayBuilder); + } else if (element instanceof JsonObject) { + final JsonObjectBuilder objectBuilder = Json.object(ignoreEmptyBuilders, skipEscapeCharacters); + objectBuilder.transform((JsonObject) element, transform); + if (!objectBuilder.isEmpty()) { + add(objectBuilder); + } + } + } } /** @@ -228,8 +283,8 @@ public static class JsonObjectBuilder extends JsonBuilder { private final Map properties; - private JsonObjectBuilder(boolean ignoreEmptyBuilders) { - super(ignoreEmptyBuilders); + private JsonObjectBuilder(boolean ignoreEmptyBuilders, boolean skipEscapeCharacters) { + super(ignoreEmptyBuilders, skipEscapeCharacters); this.properties = new HashMap(); } @@ -299,9 +354,9 @@ public void appendTo(Appendable appendable) throws IOException { if (++idx > 1) { appendable.append(ENTRY_SEPARATOR); } - appendStringValue(appendable, entry.getKey()); + appendStringValue(appendable, entry.getKey(), skipEscapeCharacters); appendable.append(NAME_VAL_SEPARATOR); - appendValue(appendable, entry.getValue()); + appendValue(appendable, entry.getValue(), skipEscapeCharacters); } appendable.append(OBJECT_END); } @@ -311,15 +366,47 @@ protected JsonObjectBuilder self() { return this; } + @Override + void add(JsonValue element) { + if (element instanceof JsonMember) { + final JsonMember member = (JsonMember) element; + final String attribute = member.attribute().value(); + final JsonValue value = member.value(); + if (value instanceof JsonString) { + put(attribute, ((JsonString) value).value()); + } else if (value instanceof JsonInteger) { + final long longValue = ((JsonInteger) value).longValue(); + final int intValue = (int) longValue; + if (longValue == intValue) { + put(attribute, intValue); + } else { + put(attribute, longValue); + } + } else if (value instanceof JsonBoolean) { + final boolean booleanValue = ((JsonBoolean) value).value(); + put(attribute, booleanValue); + } else if (value instanceof JsonArray) { + final JsonArrayBuilder arrayBuilder = Json.array(ignoreEmptyBuilders, skipEscapeCharacters); + arrayBuilder.transform((JsonArray) value, transform); + put(attribute, arrayBuilder); + } else if (value instanceof JsonObject) { + final JsonObjectBuilder objectBuilder = Json.object(ignoreEmptyBuilders, skipEscapeCharacters); + objectBuilder.transform((JsonObject) value, transform); + if (!objectBuilder.isEmpty()) { + put(attribute, objectBuilder); + } + } + } + } } - static void appendValue(Appendable appendable, Object value) throws IOException { + static void appendValue(Appendable appendable, Object value, boolean skipEscapeCharacters) throws IOException { if (value instanceof JsonObjectBuilder) { appendable.append(((JsonObjectBuilder) value).build()); } else if (value instanceof JsonArrayBuilder) { appendable.append(((JsonArrayBuilder) value).build()); } else if (value instanceof String) { - appendStringValue(appendable, value.toString()); + appendStringValue(appendable, value.toString(), skipEscapeCharacters); } else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) { appendable.append(value.toString()); } else { @@ -327,9 +414,13 @@ static void appendValue(Appendable appendable, Object value) throws IOException } } - static void appendStringValue(Appendable appendable, String value) throws IOException { + static void appendStringValue(Appendable appendable, String value, boolean skipEscapeCharacters) throws IOException { appendable.append(CHAR_QUOTATION_MARK); - appendable.append(escape(value)); + if (skipEscapeCharacters) { + appendable.append(value); + } else { + appendable.append(escape(value)); + } appendable.append(CHAR_QUOTATION_MARK); } @@ -354,4 +445,21 @@ static String escape(String value) { return builder.toString(); } + private static final class ResolvedTransform implements JsonTransform { + private final Json.JsonBuilder resolvedBuilder; + private final JsonTransform transform; + + private ResolvedTransform(Json.JsonBuilder resolvedBuilder, JsonTransform transform) { + this.resolvedBuilder = resolvedBuilder; + this.resolvedBuilder.setTransform(transform); + this.transform = transform; + } + + @Override + public void accept(Json.JsonBuilder builder, JsonValue element) { + if (builder == null) { + transform.accept(resolvedBuilder, element); + } + } + } } diff --git a/core/builder/src/main/java/io/quarkus/builder/JsonReader.java b/core/builder/src/main/java/io/quarkus/builder/JsonReader.java new file mode 100644 index 0000000000000..b0fc9f51a01e6 --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/JsonReader.java @@ -0,0 +1,351 @@ +package io.quarkus.builder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.quarkus.builder.json.JsonArray; +import io.quarkus.builder.json.JsonBoolean; +import io.quarkus.builder.json.JsonDouble; +import io.quarkus.builder.json.JsonInteger; +import io.quarkus.builder.json.JsonNull; +import io.quarkus.builder.json.JsonObject; +import io.quarkus.builder.json.JsonString; +import io.quarkus.builder.json.JsonValue; + +/** + * A json format reader. + * It follows the ECMA-404 The JSON Data Interchange Standard.. + */ +public class JsonReader { + + private final String text; + private final int length; + private int position; + + private JsonReader(String text) { + this.text = text; + this.length = text.length(); + } + + public static JsonReader of(String source) { + return new JsonReader(source); + } + + @SuppressWarnings("unchecked") + public T read() { + return (T) readElement(); + } + + /** + * element + * |---- ws value ws + */ + private JsonValue readElement() { + ignoreWhitespace(); + JsonValue result = readValue(); + ignoreWhitespace(); + return result; + } + + /** + * value + * |---- object + * |---- array + * |---- string + * |---- number + * |---- "true" + * |---- "false" + * |---- "null" + */ + private JsonValue readValue() { + final int ch = peekChar(); + if (ch < 0) { + throw new IllegalArgumentException("Unable to fully read json value"); + } + + switch (ch) { + case '{': + return readObject(); + case '[': + return readArray(); + case '"': + return readString(); + case 't': + return readConstant("true", JsonBoolean.TRUE); + case 'f': + return readConstant("false", JsonBoolean.FALSE); + case 'n': + return readConstant("null", JsonNull.INSTANCE); + default: + if (Character.isDigit(ch) || '-' == ch) { + return readNumber(position); + } + throw new IllegalArgumentException("Unknown start character for json value: " + ch); + } + } + + /** + * object + * |---- '{' ws '}' + * |---- '{' members '}' + *

+ * members + * |----- member + * |----- member ',' members + */ + private JsonValue readObject() { + position++; + + Map members = new HashMap<>(); + + while (position < length) { + ignoreWhitespace(); + switch (peekChar()) { + case '}': + position++; + return new JsonObject(members); + case ',': + position++; + break; + case '"': + readMember(members); + break; + } + } + + throw new IllegalArgumentException("Json object ended without }"); + } + + /** + * member + * |----- ws string ws ':' element + */ + private void readMember(Map members) { + final JsonString attribute = readString(); + ignoreWhitespace(); + final int colon = nextChar(); + if (':' != colon) { + throw new IllegalArgumentException("Expected : after attribute"); + } + final JsonValue element = readElement(); + members.put(attribute, element); + } + + /** + * array + * |---- '[' ws ']' + * |---- '[' elements ']' + *

+ * elements + * |----- element + * |----- element ',' elements + */ + private JsonValue readArray() { + position++; + + final List elements = new ArrayList<>(); + + while (position < length) { + ignoreWhitespace(); + switch (peekChar()) { + case ']': + position++; + return new JsonArray(elements); + case ',': + position++; + break; + default: + elements.add(readElement()); + break; + } + } + + throw new IllegalArgumentException("Json array ended without ]"); + } + + /** + * string + * |---- '"' characters '"' + *

+ * characters + * |----- "" + * |----- character characters + *

+ * character + * |----- '0020' . '10FFFF' - '"' - '\' + * |----- '\' escape + * |----- escape + * |----- '"' + * |----- '\' + * |----- '/' + * |----- 'b' + * |----- 'f' + * |----- 'n' + * |----- 'r' + * |----- 't' + * |----- 'u' hex hex hex hex + */ + private JsonString readString() { + position++; + + int start = position; + // Substring on string values that contain unicode characters won't work, + // because there are more characters read than actual characters represented. + // Use StringBuilder to buffer any string read up to unicode, + // then add unicode values into it and continue as usual. + StringBuilder unicodeString = null; + + while (position < length) { + final int ch = nextChar(); + + if (Character.isISOControl(ch)) { + throw new IllegalArgumentException("Control characters not allowed in json string"); + } + + if ('"' == ch) { + final String chunk = text.substring(start, position - 1); + final String result = unicodeString != null + ? unicodeString.append(chunk).toString() + : chunk; + + // End of string + return new JsonString(result); + } + + if ('\\' == ch) { + switch (nextChar()) { + case '"': // quotation mark + case '\\': // reverse solidus + case '/': // solidus + case 'b': // backspace + case 'f': // formfeed + case 'n': // linefeed + case 'r': // carriage return + case 't': // horizontal tab + break; + case 'u': // unicode + if (unicodeString == null) { + unicodeString = new StringBuilder(position - start); + } + unicodeString.append(text, start, position - 1); + unicodeString.append(readUnicode()); + start = position; + } + } + } + + throw new IllegalArgumentException("String not closed"); + } + + private char readUnicode() { + final char digit1 = Character.forDigit(nextChar(), 16); + final char digit2 = Character.forDigit(nextChar(), 16); + final char digit3 = Character.forDigit(nextChar(), 16); + final char digit4 = Character.forDigit(nextChar(), 16); + return (char) (digit1 << 12 | digit2 << 8 | digit3 << 4 | digit4); + } + + /** + * number + * |---- integer fraction exponent + */ + private JsonValue readNumber(int numStartIndex) { + final boolean isFraction = skipToEndOfNumber(); + final String number = text.substring(numStartIndex, position); + return isFraction + ? new JsonDouble(Double.parseDouble(number)) + : new JsonInteger(Long.parseLong(number)); + } + + private boolean skipToEndOfNumber() { + // Find the end of a number then parse with library methods + int ch = nextChar(); + if ('-' == ch) { + ch = nextChar(); + } + + if (Character.isDigit(ch) && '0' != ch) { + ignoreDigits(); + } + + boolean isFraction = false; + ch = peekChar(); + if ('.' == ch) { + isFraction = true; + position++; + ignoreDigits(); + } + + ch = peekChar(); + switch (ch) { + case 'e': + case 'E': + position++; + ch = nextChar(); + switch (ch) { + case '-': + case '+': + position++; + } + ignoreDigits(); + } + + return isFraction; + } + + private void ignoreDigits() { + while (position < length) { + final int ch = peekChar(); + if (!Character.isDigit(ch)) { + break; + } + position++; + } + } + + private JsonValue readConstant(String expected, JsonValue result) { + if (text.regionMatches(position, expected, 0, expected.length())) { + position += expected.length(); + return result; + } + throw new IllegalArgumentException("Unable to read json constant for: " + expected); + } + + /** + * ws + * |---- "" + * |---- '0020' ws + * |---- '000A' ws + * |---- '000D' ws + * |---- '0009' ws + */ + private void ignoreWhitespace() { + while (position < length) { + final int ch = peekChar(); + switch (ch) { + case ' ': // '0020' SPACE + case '\n': // '000A' LINE FEED + case '\r': // '000D' CARRIAGE RETURN + case '\t': // '0009' CHARACTER TABULATION + position++; + break; + default: + return; + } + } + } + + private int peekChar() { + return position < length + ? text.charAt(position) + : -1; + } + + private int nextChar() { + final int ch = peekChar(); + position++; + return ch; + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/JsonTransform.java b/core/builder/src/main/java/io/quarkus/builder/JsonTransform.java new file mode 100644 index 0000000000000..c6994add0ed69 --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/JsonTransform.java @@ -0,0 +1,17 @@ +package io.quarkus.builder; + +import java.util.function.Predicate; + +import io.quarkus.builder.json.JsonValue; + +@FunctionalInterface +public interface JsonTransform { + void accept(Json.JsonBuilder builder, JsonValue element); + + static JsonTransform dropping(Predicate filter) { + return (builder, element) -> { + if (!filter.test(element)) + builder.add(element); + }; + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonArray.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonArray.java new file mode 100644 index 0000000000000..f88c4f9feb89a --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonArray.java @@ -0,0 +1,28 @@ +package io.quarkus.builder.json; + +import java.util.List; +import java.util.stream.Stream; + +import io.quarkus.builder.JsonTransform; + +public final class JsonArray implements JsonMultiValue { + private final List value; + + public JsonArray(List value) { + this.value = value; + } + + public List value() { + return value; + } + + @SuppressWarnings("unchecked") + public Stream stream() { + return (Stream) value.stream(); + } + + @Override + public void forEach(JsonTransform transform) { + value.forEach(v -> transform.accept(null, v)); + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonBoolean.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonBoolean.java new file mode 100644 index 0000000000000..da01cf9aa28cc --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonBoolean.java @@ -0,0 +1,16 @@ +package io.quarkus.builder.json; + +public enum JsonBoolean implements JsonValue { + TRUE(true), + FALSE(false); + + private final boolean value; + + JsonBoolean(boolean value) { + this.value = value; + } + + public boolean value() { + return value; + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonDouble.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonDouble.java new file mode 100644 index 0000000000000..8a567401f829a --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonDouble.java @@ -0,0 +1,13 @@ +package io.quarkus.builder.json; + +public final class JsonDouble implements JsonNumber { + private final double value; + + public JsonDouble(double value) { + this.value = value; + } + + public double value() { + return value; + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonInteger.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonInteger.java new file mode 100644 index 0000000000000..062d613de7411 --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonInteger.java @@ -0,0 +1,17 @@ +package io.quarkus.builder.json; + +public final class JsonInteger implements JsonNumber { + private final long value; + + public JsonInteger(long value) { + this.value = value; + } + + public long longValue() { + return value; + } + + public int intValue() { + return (int) value; + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonMember.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonMember.java new file mode 100644 index 0000000000000..1ce4b2a88797c --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonMember.java @@ -0,0 +1,19 @@ +package io.quarkus.builder.json; + +public final class JsonMember implements JsonValue { + private final JsonString attribute; + private final JsonValue value; + + public JsonMember(JsonString attribute, JsonValue value) { + this.attribute = attribute; + this.value = value; + } + + public JsonString attribute() { + return attribute; + } + + public JsonValue value() { + return value; + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonMultiValue.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonMultiValue.java new file mode 100644 index 0000000000000..42ce6b88d992c --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonMultiValue.java @@ -0,0 +1,9 @@ +package io.quarkus.builder.json; + +import io.quarkus.builder.JsonTransform; + +public interface JsonMultiValue extends JsonValue { + default void forEach(JsonTransform transform) { + transform.accept(null, this); + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonNull.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonNull.java new file mode 100644 index 0000000000000..13c8d995bc643 --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonNull.java @@ -0,0 +1,5 @@ +package io.quarkus.builder.json; + +public enum JsonNull implements JsonValue { + INSTANCE; +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonNumber.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonNumber.java new file mode 100644 index 0000000000000..2c25838dca80b --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonNumber.java @@ -0,0 +1,4 @@ +package io.quarkus.builder.json; + +public interface JsonNumber extends JsonValue { +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonObject.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonObject.java new file mode 100644 index 0000000000000..6718d8687ad60 --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonObject.java @@ -0,0 +1,31 @@ +package io.quarkus.builder.json; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import io.quarkus.builder.JsonTransform; + +public final class JsonObject implements JsonMultiValue { + private final Map value; + + public JsonObject(Map value) { + this.value = value; + } + + @SuppressWarnings("unchecked") + public T get(String attribute) { + return (T) value.get(new JsonString(attribute)); + } + + public List members() { + return value.entrySet().stream() + .map(e -> new JsonMember(e.getKey(), e.getValue())) + .collect(Collectors.toList()); + } + + @Override + public void forEach(JsonTransform transform) { + members().forEach(member -> transform.accept(null, member)); + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonString.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonString.java new file mode 100644 index 0000000000000..5f13517539e05 --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonString.java @@ -0,0 +1,30 @@ +package io.quarkus.builder.json; + +import java.util.Objects; + +public final class JsonString implements JsonValue { + private final String value; + + public JsonString(String value) { + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + JsonString that = (JsonString) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } +} diff --git a/core/builder/src/main/java/io/quarkus/builder/json/JsonValue.java b/core/builder/src/main/java/io/quarkus/builder/json/JsonValue.java new file mode 100644 index 0000000000000..a990951d5679b --- /dev/null +++ b/core/builder/src/main/java/io/quarkus/builder/json/JsonValue.java @@ -0,0 +1,4 @@ +package io.quarkus.builder.json; + +public interface JsonValue { +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/nativeimage/NativeImageAgentConfigDirectoryBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/nativeimage/NativeImageAgentConfigDirectoryBuildItem.java new file mode 100644 index 0000000000000..c85bd992bccb8 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/nativeimage/NativeImageAgentConfigDirectoryBuildItem.java @@ -0,0 +1,25 @@ +package io.quarkus.deployment.builditem.nativeimage; + +import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.deployment.pkg.NativeConfig; + +/** + * Native configuration generated by native image agent can be integrated + * directly into subsequence native build steps, + * if the user enables {@link NativeConfig#agentConfigurationApply()}. + * This build item is used to transfer the native configuration folder path + * onto the {@link io.quarkus.deployment.pkg.steps.NativeImageBuildStep}. + * If the build item is passed, + * the directory is added to the native image build execution. + */ +public final class NativeImageAgentConfigDirectoryBuildItem extends SimpleBuildItem { + private final String directory; + + public NativeImageAgentConfigDirectoryBuildItem(String directory) { + this.directory = directory; + } + + public String getDirectory() { + return directory; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/pkg/NativeConfig.java b/core/deployment/src/main/java/io/quarkus/deployment/pkg/NativeConfig.java index 310090fd7c1ee..5f25613e770c1 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/pkg/NativeConfig.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/pkg/NativeConfig.java @@ -486,6 +486,19 @@ interface Debug { */ Compression compression(); + /** + * Configuration files generated by the Quarkus build, using native image agent, are informative by default. + * In other words, the generated configuration files are presented in the build log but are not applied. + * When this option is set to true, generated configuration files are applied to the native executable building process. + *

+ * Enabling this option should be done with care, because it can make native image configuration and/or behaviour + * dependant on other non-obvious factors. For example, if the native image agent generated configuration was generated + * from running JVM unit tests, disabling test(s) can result in a different native image configuration being generated, + * which in turn can misconfigure the native executable or affect its behaviour in unintended ways. + */ + @WithDefault("false") + boolean agentConfigurationApply(); + @ConfigGroup interface Compression { /** diff --git a/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/NativeImageBuildLocalContainerRunner.java b/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/NativeImageBuildLocalContainerRunner.java index 1afb99e41f7fd..c2ec3493c1694 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/NativeImageBuildLocalContainerRunner.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/NativeImageBuildLocalContainerRunner.java @@ -11,40 +11,54 @@ import org.apache.commons.lang3.SystemUtils; import io.quarkus.deployment.pkg.NativeConfig; +import io.quarkus.deployment.util.ContainerRuntimeUtil.ContainerRuntime; import io.quarkus.deployment.util.FileUtil; public class NativeImageBuildLocalContainerRunner extends NativeImageBuildContainerRunner { public NativeImageBuildLocalContainerRunner(NativeConfig nativeConfig) { super(nativeConfig); + List containerRuntimeArgs = new ArrayList<>(Arrays.asList(baseContainerRuntimeArgs)); + if (SystemUtils.IS_OS_LINUX && containerRuntime.isInWindowsWSL()) { + containerRuntimeArgs.add("--interactive"); + } + containerRuntimeArgs.addAll(getVolumeAccessArguments(containerRuntime)); + baseContainerRuntimeArgs = containerRuntimeArgs.toArray(baseContainerRuntimeArgs); + } + + public static List getVolumeAccessArguments(ContainerRuntime containerRuntime) { + final List result = new ArrayList<>(); if (SystemUtils.IS_OS_LINUX || SystemUtils.IS_OS_MAC) { - final ArrayList containerRuntimeArgs = new ArrayList<>(Arrays.asList(baseContainerRuntimeArgs)); - if (containerRuntime.isInWindowsWSL()) { - containerRuntimeArgs.add("--interactive"); - } if (containerRuntime.isDocker() && containerRuntime.isRootless()) { - Collections.addAll(containerRuntimeArgs, "--user", String.valueOf(0)); + Collections.addAll(result, "--user", String.valueOf(0)); } else { String uid = getLinuxID("-ur"); String gid = getLinuxID("-gr"); if (uid != null && gid != null && !uid.isEmpty() && !gid.isEmpty()) { - Collections.addAll(containerRuntimeArgs, "--user", uid + ":" + gid); + Collections.addAll(result, "--user", uid + ":" + gid); if (containerRuntime.isPodman() && containerRuntime.isRootless()) { // Needed to avoid AccessDeniedExceptions - containerRuntimeArgs.add("--userns=keep-id"); + result.add("--userns=keep-id"); } } } - baseContainerRuntimeArgs = containerRuntimeArgs.toArray(baseContainerRuntimeArgs); } + return result; } @Override protected List getContainerRuntimeBuildArgs(Path outputDir) { final List containerRuntimeArgs = super.getContainerRuntimeBuildArgs(outputDir); String volumeOutputPath = outputDir.toAbsolutePath().toString(); + addVolumeParameter(volumeOutputPath, NativeImageBuildStep.CONTAINER_BUILD_VOLUME_PATH, containerRuntimeArgs, + containerRuntime); + return containerRuntimeArgs; + } + + public static void addVolumeParameter(String localPath, String remotePath, List args, + ContainerRuntime containerRuntime) { if (SystemUtils.IS_OS_WINDOWS) { - volumeOutputPath = FileUtil.translateToVolumePath(volumeOutputPath); + localPath = FileUtil.translateToVolumePath(localPath); } final String selinuxBindOption; @@ -54,9 +68,7 @@ protected List getContainerRuntimeBuildArgs(Path outputDir) { selinuxBindOption = ":z"; } - Collections.addAll(containerRuntimeArgs, "-v", - volumeOutputPath + ":" + NativeImageBuildStep.CONTAINER_BUILD_VOLUME_PATH + selinuxBindOption); - return containerRuntimeArgs; + args.add("-v"); + args.add(localPath + ":" + remotePath + selinuxBindOption); } - } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/NativeImageBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/NativeImageBuildStep.java index bf483fae4621f..1c1d8f54b73e1 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/NativeImageBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/NativeImageBuildStep.java @@ -28,6 +28,7 @@ import io.quarkus.deployment.builditem.SuppressNonRuntimeConfigChangedWarningBuildItem; import io.quarkus.deployment.builditem.nativeimage.ExcludeConfigBuildItem; import io.quarkus.deployment.builditem.nativeimage.JPMSExportBuildItem; +import io.quarkus.deployment.builditem.nativeimage.NativeImageAgentConfigDirectoryBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageAllowIncompleteClasspathAggregateBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageEnableModule; import io.quarkus.deployment.builditem.nativeimage.NativeImageSecurityProviderBuildItem; @@ -178,6 +179,7 @@ public NativeImageBuildItem build(NativeConfig nativeConfig, LocalesBuildTimeCon Optional processInheritIODisabled, Optional processInheritIODisabledBuildItem, List nativeImageFeatures, + Optional nativeImageAgentConfigDirectoryBuildItem, NativeImageRunnerBuildItem nativeImageRunner) { if (nativeConfig.debug().enabled()) { copyJarSourcesToLib(outputTargetBuildItem, curateOutcomeBuildItem); @@ -245,6 +247,7 @@ public NativeImageBuildItem build(NativeConfig nativeConfig, LocalesBuildTimeCon .setGraalVMVersion(graalVMVersion) .setNativeImageFeatures(nativeImageFeatures) .setContainerBuild(isContainerBuild) + .setNativeImageAgentConfigDirectory(nativeImageAgentConfigDirectoryBuildItem) .build(); List nativeImageArgs = commandAndExecutable.args; @@ -593,12 +596,19 @@ static class Builder { private String nativeImageName; private boolean classpathIsBroken; private boolean containerBuild; + private Optional nativeImageAgentConfigDirectory = Optional.empty(); public Builder setNativeConfig(NativeConfig nativeConfig) { this.nativeConfig = nativeConfig; return this; } + public Builder setNativeImageAgentConfigDirectory( + Optional nativeImageAgentConfigDirectory) { + this.nativeImageAgentConfigDirectory = nativeImageAgentConfigDirectory; + return this; + } + public Builder setLocalesBuildTimeConfig(LocalesBuildTimeConfig localesBuildTimeConfig) { this.localesBuildTimeConfig = localesBuildTimeConfig; return this; @@ -983,6 +993,9 @@ public NativeImageInvokerInfo build() { } } + nativeImageAgentConfigDirectory + .ifPresent(dir -> nativeImageArgs.add("-H:ConfigurationFileDirectories=" + dir.getDirectory())); + for (ExcludeConfigBuildItem excludeConfig : excludeConfigs) { nativeImageArgs.add("--exclude-config"); nativeImageArgs.add(excludeConfig.getJarFile()); diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ApplyNativeImageAgentConfigStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ApplyNativeImageAgentConfigStep.java new file mode 100644 index 0000000000000..ca68bac029d2f --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ApplyNativeImageAgentConfigStep.java @@ -0,0 +1,58 @@ +package io.quarkus.deployment.steps; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +import org.jboss.logging.Logger; + +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.nativeimage.NativeImageAgentConfigDirectoryBuildItem; +import io.quarkus.deployment.pkg.NativeConfig; +import io.quarkus.deployment.pkg.builditem.BuildSystemTargetBuildItem; +import io.quarkus.deployment.pkg.builditem.NativeImageSourceJarBuildItem; +import io.quarkus.deployment.pkg.steps.NativeOrNativeSourcesBuild; + +/** + * This configuration step looks for native configuration folder generated + * with the native image agent running inside Quarkus integration tests. + * If the folder is detected and {@link NativeConfig#agentConfigurationApply()} is enabled, + * the folder's path is passed onto the {@link io.quarkus.deployment.pkg.steps.NativeImageBuildStep}, + * wrapped inside a {@link NativeImageAgentConfigDirectoryBuildItem}, + * so that the folder is added as a configuration folder for the native image process execution. + */ +public class ApplyNativeImageAgentConfigStep { + private static final Logger log = Logger.getLogger(ApplyNativeImageAgentConfigStep.class); + + @BuildStep(onlyIf = NativeOrNativeSourcesBuild.class) + void transformConfig(NativeConfig nativeConfig, + BuildProducer nativeImageAgentConfigDirectoryProducer, + NativeImageSourceJarBuildItem nativeImageSourceJarBuildItem, + BuildSystemTargetBuildItem buildSystemTargetBuildItem) throws IOException { + final Path basePath = buildSystemTargetBuildItem.getOutputDirectory() + .resolve(Path.of("native-image-agent-final-config")); + if (basePath.toFile().exists() && nativeConfig.agentConfigurationApply()) { + final Path outputDir = nativeImageSourceJarBuildItem.getPath().getParent(); + final String targetDirName = "native-image-agent-config"; + final Path targetPath = outputDir.resolve(Path.of(targetDirName)); + if (!targetPath.toFile().exists()) { + targetPath.toFile().mkdirs(); + } + Files.copy(basePath.resolve("reflect-config.json"), targetPath.resolve("reflect-config.json"), + StandardCopyOption.REPLACE_EXISTING); + Files.copy(basePath.resolve("serialization-config.json"), targetPath.resolve("serialization-config.json"), + StandardCopyOption.REPLACE_EXISTING); + Files.copy(basePath.resolve("jni-config.json"), targetPath.resolve("jni-config.json"), + StandardCopyOption.REPLACE_EXISTING); + Files.copy(basePath.resolve("proxy-config.json"), targetPath.resolve("proxy-config.json"), + StandardCopyOption.REPLACE_EXISTING); + Files.copy(basePath.resolve("resource-config.json"), targetPath.resolve("resource-config.json"), + StandardCopyOption.REPLACE_EXISTING); + + log.info("Applying native image agent generated files to current native executable build"); + nativeImageAgentConfigDirectoryProducer.produce(new NativeImageAgentConfigDirectoryBuildItem(targetDirName)); + } + } +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/pkg/TestNativeConfig.java b/core/deployment/src/test/java/io/quarkus/deployment/pkg/TestNativeConfig.java index 0a0f28227bb26..4c276528b2494 100644 --- a/core/deployment/src/test/java/io/quarkus/deployment/pkg/TestNativeConfig.java +++ b/core/deployment/src/test/java/io/quarkus/deployment/pkg/TestNativeConfig.java @@ -230,6 +230,11 @@ public Compression compression() { return null; } + @Override + public boolean agentConfigurationApply() { + return false; + } + private class TestBuildImageConfig implements BuilderImageConfig { private final String image; private final ImagePullStrategy pull; diff --git a/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeTestsMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeTestsMojo.java index a5a2644e8f16c..b644af7be0a18 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeTestsMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/GenerateCodeTestsMojo.java @@ -1,16 +1,109 @@ package io.quarkus.maven; +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.ResolutionScope; +import io.quarkus.bootstrap.app.CuratedApplication; +import io.quarkus.builder.Json; +import io.quarkus.maven.dependency.ResolvedDependency; +import io.quarkus.runtime.LaunchMode; + @Mojo(name = "generate-code-tests", defaultPhase = LifecyclePhase.GENERATE_TEST_SOURCES, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, threadSafe = true) public class GenerateCodeTestsMojo extends GenerateCodeMojo { @Override protected void doExecute() throws MojoExecutionException, MojoFailureException { generateCode(getParentDirs(mavenProject().getTestCompileSourceRoots()), path -> mavenProject().addTestCompileSourceRoot(path.toString()), true); + + if (isTestWithNativeAgent()) { + generateNativeAgentFilters(); + } + } + + private boolean isTestWithNativeAgent() { + String value = System.getProperty("quarkus.test.integration-test-profile"); + if ("test-with-native-agent".equals(value)) { + return true; + } + + final Object obj = mavenProject().getProperties().get("quarkus.test.integration-test-profile"); + return obj != null && "test-with-native-agent".equals(obj.toString()); + } + + private void generateNativeAgentFilters() throws MojoExecutionException { + getLog().debug("Generate native image agent filters"); + + // Get packages to exclude + Collection commonExcludePackageNames = getCommonExcludePackageNames(); + + // Generate json using the packages + generateNativeAgentFilter(commonExcludePackageNames, + Path.of(mavenProject().getModel().getBuild().getDirectory(), + "quarkus-caller-filter.json")); + generateNativeAgentFilter(commonExcludePackageNames, + Path.of(mavenProject().getModel().getBuild().getDirectory(), + "quarkus-access-filter.json")); + } + + private Collection getAccessExcludePackageNames(Collection commonExcludePackageNames) { + final Set result = new HashSet<>(commonExcludePackageNames); + // Quarkus bootstrap depends on CRaC on startup and its APIs do reflection lookups. + // These should be excluded from generated configuration because Quarkus takes care of it. + result.add("javax.crac"); + result.add("jdk.crac"); + return result; + } + + private void generateNativeAgentFilter(Collection packageNames, Path path) throws MojoExecutionException { + final Json.JsonObjectBuilder result = Json.object(); + + final Json.JsonArrayBuilder rules = Json.array(); + packageNames.stream() + .map(packageName -> Json.object().put("excludeClasses", packageName + ".**")) + .forEach(rules::add); + result.put("rules", rules); + + final Json.JsonArrayBuilder regexRules = Json.array(); + regexRules.add(Json.object().put("excludeClasses", ".*_Bean")); + result.put("regexRules", regexRules); + + try (BufferedWriter writer = new BufferedWriter(new FileWriter(path.toFile(), StandardCharsets.UTF_8))) { + result.appendTo(writer); + } catch (IOException e) { + throw new MojoExecutionException("Unable to write quarkus native image agent caller filter to " + path, e); + } + } + + private Collection getDependencies() throws MojoExecutionException { + try (CuratedApplication curatedApplication = bootstrapApplication(LaunchMode.TEST)) { + return curatedApplication.getApplicationModel().getDependencies(); + } catch (Exception any) { + throw new MojoExecutionException("Quarkus native image agent filter generation phase has failed", any); + } + } + + private Set getCommonExcludePackageNames() { + Set packageNames = new HashSet<>(); + // Any calls that access or originate in these packages + // that require native configuration should be handled by Quarkus. + packageNames.add("io.netty"); + packageNames.add("io.quarkus"); + packageNames.add("io.smallrye"); + packageNames.add("io.vertx"); + packageNames.add("jakarta"); + packageNames.add("org.jboss"); + return packageNames; } } diff --git a/devtools/maven/src/main/java/io/quarkus/maven/NativeImageAgentMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/NativeImageAgentMojo.java new file mode 100644 index 0000000000000..9737d327586d4 --- /dev/null +++ b/devtools/maven/src/main/java/io/quarkus/maven/NativeImageAgentMojo.java @@ -0,0 +1,126 @@ +package io.quarkus.maven; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.ResolutionScope; + +import io.quarkus.builder.Json; +import io.quarkus.builder.JsonReader; +import io.quarkus.builder.JsonTransform; +import io.quarkus.builder.json.JsonMember; +import io.quarkus.builder.json.JsonObject; +import io.quarkus.builder.json.JsonString; +import io.quarkus.builder.json.JsonValue; + +/** + * Post-processes native image agent generated configuration to trim any unnecessary configuration. + */ +@Mojo(name = "native-image-agent", defaultPhase = LifecyclePhase.POST_INTEGRATION_TEST, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, threadSafe = true) +public class NativeImageAgentMojo extends QuarkusBootstrapMojo { + + private final Pattern resourceSkipPattern; + + public NativeImageAgentMojo() { + // Exclude resource configuration for resources that Quarkus takes care of registering. + resourceSkipPattern = discardPattern("application.properties", "jakarta", "jboss", + "logging.properties", "microprofile", + "quarkus", "slf4j", "smallrye", "vertx"); + } + + @Override + protected boolean beforeExecute() throws MojoExecutionException, MojoFailureException { + // Only execute transformation if integration tests were run in JVM mode + return !QuarkusBootstrapMojo.isNativeProfileEnabled(mavenProject()); + } + + @Override + protected void doExecute() throws MojoExecutionException, MojoFailureException { + final String dirName = "native-image-agent-base-config"; + final Path basePath = buildDir().toPath().resolve(Path.of(dirName)); + getLog().debug("Checking if native image agent config folder exits at " + basePath); + if (basePath.toFile().exists()) { + try { + final Path targetPath = buildDir().toPath().resolve(Path.of("native-image-agent-final-config")); + if (!targetPath.toFile().exists()) { + targetPath.toFile().mkdirs(); + } + getLog().debug("Native image agent config folder exits, copy and transform to " + targetPath); + final Path reflectConfigJsonPath = basePath.resolve("reflect-config.json"); + if (reflectConfigJsonPath.toFile().exists()) { + Files.copy(reflectConfigJsonPath, targetPath.resolve("reflect-config.json"), + StandardCopyOption.REPLACE_EXISTING); + Files.copy(basePath.resolve("serialization-config.json"), targetPath.resolve("serialization-config.json"), + StandardCopyOption.REPLACE_EXISTING); + Files.copy(basePath.resolve("jni-config.json"), targetPath.resolve("jni-config.json"), + StandardCopyOption.REPLACE_EXISTING); + Files.copy(basePath.resolve("proxy-config.json"), targetPath.resolve("proxy-config.json"), + StandardCopyOption.REPLACE_EXISTING); + transformJsonObject(basePath, "resource-config.json", targetPath, + JsonTransform.dropping(this::discardResource)); + + if (getLog().isInfoEnabled()) { + getLog().info("Discovered native image agent generated files in " + targetPath); + } + } else { + final Path reflectOriginsTxtPath = basePath.resolve("reflect-origins.txt"); + if (reflectOriginsTxtPath.toFile().exists()) { + getLog().info("Native image agent configuration origin files exist, inspect them manually inside " + + basePath); + } + } + } catch (IOException e) { + throw new MojoExecutionException("Failed to transform native image agent configuration", e); + } + } else { + getLog().info("Missing " + dirName + " directory with native image agent configuration to transform"); + } + } + + private void transformJsonObject(Path base, String name, Path target, JsonTransform transform) throws IOException { + getLog().debug("Discarding resources from native image configuration that match the following regular expression: " + + resourceSkipPattern); + final String original = Files.readString(base.resolve(name)); + final JsonObject jsonRead = JsonReader.of(original).read(); + final Json.JsonObjectBuilder jsonBuilder = Json.object(false, true); + jsonBuilder.transform(jsonRead, transform); + + try (BufferedWriter writer = new BufferedWriter( + new FileWriter(target.resolve(name).toFile(), StandardCharsets.UTF_8))) { + jsonBuilder.appendTo(writer); + } + } + + private boolean discardResource(JsonValue value) { + if (value instanceof JsonMember) { + final JsonMember member = (JsonMember) value; + if ("pattern".equals(member.attribute().value())) { + final JsonString memberValue = (JsonString) member.value(); + final boolean discarded = resourceSkipPattern.matcher(memberValue.value()).find(); + if (discarded) { + getLog().debug("Discarded included resource with pattern: " + memberValue.value()); + } + return discarded; + } + } + + return false; + } + + private static Pattern discardPattern(String... ignoredElements) { + final String pattern = Arrays.stream(ignoredElements).collect(Collectors.joining("|", ".*(", ").*")); + return Pattern.compile(pattern); + } +} diff --git a/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java index 0a70ff33f9bfb..1ead48cabc663 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java @@ -326,7 +326,7 @@ protected boolean setNativeEnabledIfNativeProfileEnabled() { } } - private boolean isNativeProfileEnabled(MavenProject mavenProject) { + static boolean isNativeProfileEnabled(MavenProject mavenProject) { // gotcha: mavenProject.getActiveProfiles() does not always contain all active profiles (sic!), // but getInjectedProfileIds() does (which has to be "flattened" first) Stream activeProfileIds = mavenProject.getInjectedProfileIds().values().stream().flatMap(List::stream); @@ -334,6 +334,6 @@ private boolean isNativeProfileEnabled(MavenProject mavenProject) { return true; } // recurse into parent (if available) - return Optional.ofNullable(mavenProject.getParent()).map(this::isNativeProfileEnabled).orElse(false); + return Optional.ofNullable(mavenProject.getParent()).map(QuarkusBootstrapMojo::isNativeProfileEnabled).orElse(false); } } diff --git a/docs/src/main/asciidoc/native-reference.adoc b/docs/src/main/asciidoc/native-reference.adoc index 8c7d741a5a8b1..b28ec249a1516 100644 --- a/docs/src/main/asciidoc/native-reference.adoc +++ b/docs/src/main/asciidoc/native-reference.adoc @@ -528,6 +528,283 @@ ${FG_HOME}/stackcollapse.pl < out.stacks | ${FG_HOME}/flamegraph.pl \ --color=mem --title="mmap munmap Flame Graph" --countname="calls" > out.svg ---- +[[native-image-agent-integration]] +== Native Image Tracing Agent Integration + +Quarkus users that want to integrate new libraries/components into native image process +(e.g. link:https://github.com/hierynomus/smbj[smbj]), +or want to use JDK APIs that require extensive native image configuration to work (e.g. graphical user interfaces), +face a considerable challenge coming up with the native image configuration to make their use cases work. +These users can tweak their applications to run in JVM mode with the native image agent in order to +auto-generate native image configuration that will help them get a head start getting applications to work as native executables. + +The native image tracing agent is a JVM tool interface (JVMTI) agent available within both GraalVM and Mandrel that +tracks all usages of dynamic features such as reflection, JNI, dynamic proxies, access classpath resources...etc, +during an application's regular JVM execution. +When the JVM stops, it dumps the information on the dynamic features used during the run +onto a collection of native image configuration files that can be used in subsequent native image builds. + +Using the agent and applying the generated data can be difficult for Quarkus users. +First, the agent can be cumbersome because it requires the JVM arguments to be modified, +and the generated configuration needs to be placed in a specific location such that the subsequent native image builds picks them up. +Secondly, the native image configuration produced contains a lot of superfluous configuration that the Quarkus integration takes care of. + +Native image tracing agent integration is included in Quarkus to make the agent easier to consume. +In this section you will learn about the integration and how to apply it to your Quarkus application. + +[NOTE] +==== +The integration is currently only available for Maven applications. +link:https://github.com/quarkusio/quarkus/issues/40361[Gradle integration] will follow up. +==== + +=== Integration Testing with the Tracing Agent + +Quarkus users can now run JVM mode integration tests on Quarkus Maven applications transparently with the native image tracing agent. +To do this make sure a container runtime is available, +because JVM mode integration tests will run using the JVM within the default Mandrel builder container image. +This image contains the agent libraries required to produce native image configuration, +hence avoiding the need for a local Mandrel or GraalVM installation. + +[TIP] +==== +It is highly recommended to align the Mandrel version used in integration testing +with the Mandrel version used to build native executables. +Doing in-container native builds with the default Mandrel builder image, +is the safest way to keep both versions aligned. +==== + +Additionally make sure that the `native-image-agent` goal is present in the `quarkus-maven-plugin` configuration: + +[source,bash] +---- + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ... + + + + ... + native-image-agent + + + + +---- + +With a container runtime running, +invoke Maven's `verify` goal with `-DskipITs=false -Dquarkus.test.integration-test-profile=test-with-native-agent` to run the JVM mode integration tests and +generate the native image configuration. +For example: + +[source,bash] +---- +$ ./mvnw verify -DskipITs=false -Dquarkus.test.integration-test-profile=test-with-native-agent +... +[INFO] --- failsafe:3.2.5:integration-test (default) @ new-project --- +... +[INFO] ------------------------------------------------------- +[INFO] T E S T S +[INFO] ------------------------------------------------------- +[INFO] Running org.acme.GreetingResourceIT +2024-05-14 16:29:53,941 INFO [io.qua.tes.com.DefaultDockerContainerLauncher] (main) Executing "podman run --name quarkus-integration-test-PodgW -i --rm --user 501:20 -p 8081:8081 -p 8444:8444 --entrypoint java -v /tmp/new-project/target:/project --env QUARKUS_LOG_CATEGORY__IO_QUARKUS__LEVEL=INFO --env QUARKUS_HTTP_PORT=8081 --env QUARKUS_HTTP_SSL_PORT=8444 --env TEST_URL=http://localhost:8081 --env QUARKUS_PROFILE=test-with-native-agent --env QUARKUS_TEST_INTEGRATION_TEST_PROFILE=test-with-native-agent quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21 -agentlib:native-image-agent=access-filter-file=quarkus-access-filter.json,caller-filter-file=quarkus-caller-filter.json,config-output-dir=native-image-agent-base-config, -jar quarkus-app/quarkus-run.jar" +... +[INFO] +[INFO] --- quarkus:{quarkus-version}:native-image-agent (default) @ new-project --- +[INFO] Discovered native image agent generated files in /tmp/new-project/target/native-image-agent-final-config +[INFO] +... +---- + +When the Maven invocation completes, +you can inspect the generated configuration in the `target/native-image-agent-final-config` folder: + +[source,bash] +---- +$ cat ./target/native-image-agent-final-config/reflect-config.json +[ +... +{ + "name":"org.acme.Alice", + "methods":[{"name":"","parameterTypes":[] }, {"name":"sayMyName","parameterTypes":[] }] +}, +{ + "name":"org.acme.Bob" +}, +... +] +---- + +=== Informative By Default + +By default the generated native image configuration files are not used by subsequent native image building processes. +This precaution is taken to avoid situations where seemingly unrelated actions have unintended consequences on the native executable produced, +e.g. disabling randomly failing tests. + +Quarkus users are free to copy the files from the folder reported in the build, +store them under source control and evolve as needed. +Ideally these files should be stored under the `src/main/resources/META-INF/native-image//`` folder, +in which case the native image process will automatically pick them up. + +[WARNING] +==== +If managing native image agent configuration files manually, +it is highly recommended to regenerate them each time a Mandrel version update occurs, +because the configuration necessary to make the application work might have varied due to internal Mandrel changes. +==== + +It is possible to instruct Quarkus to optionally apply the generated native image configuration files into subsequent native image processes, +by setting the -Dquarkus.native.agent-configuration-apply` property. +This can be useful to verify that the native integration tests work as expected, +assuming that the JVM unit tests have generated the correct native image configuration. +The typical workflow here would be to first run the integration tests with the native image agent as shown in the previous section: + +[source,bash] +---- +$ ./mvnw verify -DskipITs=false -Dquarkus.test.integration-test-profile=test-with-native-agent +... +[INFO] --- quarkus:{quarkus-version}:native-image-agent (default) @ new-project --- +[INFO] Discovered native image agent generated files in /tmp/new-project/target/native-image-agent-final-config +---- + +And then request a native build passing in the configuration apply flag. +A message during the native build process will indicate that the native image agent generated configuration files are being applied: + +[source,bash] +---- +$ ./mvnw verify -Dnative -Dquarkus.native.agent-configuration-apply +... +[INFO] --- quarkus:{quarkus-version}:build (default) @ new-project --- +[INFO] [io.quarkus.deployment.pkg.steps.JarResultBuildStep] Building native image source jar: /tmp/new-project/target/new-project-1.0.0-SNAPSHOT-native-image-source-jar/new-project-1.0.0-SNAPSHOT-runner.jar +[INFO] [io.quarkus.deployment.steps.ApplyNativeImageAgentConfigStep] Applying native image agent generated files to current native executable build +---- + +=== Debugging the Tracing Agent Integration + +If the generated native image agent configuration is not satisfactory, +more information can be obtained using any of the following techniques: + +==== Debugging Filters + +Quarkus generates native image tracing agent configuration filters. +These filters exclude commonly used packages for which Quarkus already applies the necessary configuration. + +If native image agent is generating a configuration that it’s not working as expected, +you should check that the configuration files include the expected information. +For example, if some method happens to be accessed via reflection at runtime and you get an error, +you want to verify that the configuration file contains a reflection entry for that method. + +If the entry is missing, it could be that some call path is being filtered that maybe shouldn’t have been. +To verify that, inspect the contents of `target/quarkus-caller-filter.json` and `target/quarkus-access-filter.json` files, +and confirm that the class and/or package making the call or being accessed is not being filtered out. + +If the missing entry is related to some resource, +you should inspect the Quarkus build debug output and verify which resource patterns are being discarded, e.g. + +[source,bash] +---- +$ ./mvnw -X verify -DskipITs=false -Dquarkus.test.integration-test-profile=test-with-native-agent +... +[INFO] --- quarkus:{quarkus-version}:native-image-agent (default) @ new-project --- +... +[DEBUG] Discarding resources from native image configuration that match the following regular expression: .*(application.properties|jakarta|jboss|logging.properties|microprofile|quarkus|slf4j|smallrye|vertx).* +[DEBUG] Discarded included resource with pattern: \\QMETA-INF/microprofile-config.properties\\E +[DEBUG] Discarded included resource with pattern: \\QMETA-INF/services/io.quarkus.arc.ComponentsProvider\\E +... +---- + +==== Tracing Agent Logging + +The native image tracing agent can log the method invocations that result in the generated configuration to a JSON file. +This can help understand why a configuration entry is generated. +To enable this logging, +`-Dquarkus.test.native.agent.output.property.name=trace-output` and +`-Dquarkus.test.native.agent.output.property.value=native-image-agent-trace-file.json` +system properties need to be added. +For example: + +[source,bash] +---- +$ ./mvnw verify -DskipITs=false \ + -Dquarkus.test.integration-test-profile=test-with-native-agent \ + -Dquarkus.test.native.agent.output.property.name=trace-output \ + -Dquarkus.test.native.agent.output.property.value=native-image-agent-trace-file.json +---- + +When trace output is configured, no native image configuration is generated, +and instead a `target/native-image-agent-trace-file.json` file is generated that contains trace information. +For example: + +[source,json] +---- +[ +{"tracer":"meta", "event":"initialization", "version":"1"}, +{"tracer":"meta", "event":"phase_change", "phase":"start"}, +{"tracer":"jni", "function":"FindClass", "caller_class":"java.io.ObjectStreamClass", "result":true, "args":["java/lang/NoSuchMethodError"]}, +... +{"tracer":"reflect", "function":"findConstructorHandle", "class":"io.vertx.core.impl.VertxImpl$1$1$$Lambda/0x000000f80125f4e8", "caller_class":"java.lang.invoke.InnerClassLambdaMetafactory", "result":true, "args":[["io.vertx.core.Handler"]]}, +{"tracer":"meta", "event":"phase_change", "phase":"dead"}, +{"tracer":"meta", "event":"phase_change", "phase":"unload"} +] +---- + +Unfortunately the trace output does not take into account the applied configuration filters, +so the output contains all configuration decisions made by the agent. +This is unlikely to change in the near future +(see link:https://github.com/oracle/graal/issues/7635[oracle/graal#7635]). + +==== Configuration With Origins (Experimental) + +Alternative to the trace output, +it is possible to configure the native image agent with an experimental flag that shows the origins of the configuration entries. +You can enable that with the following additional system property: + +[source,bash] +---- +$ ./mvnw verify -DskipITs=false \ + -Dquarkus.test.integration-test-profile=test-with-native-agent \ + -Dquarkus.test.native.agent.additional.args=experimental-configuration-with-origins +---- + +The origins of the configuration entries can be found in text files inside the `target/native-image-agent-base-config` folder. +For example: + +[source,bash] +---- +$ cat target/native-image-agent-base-config/reflect-origins.txt +root +├── java.lang.Thread#run() +│ └── java.lang.Thread#runWith(java.lang.Object,java.lang.Runnable) +│ └── io.netty.util.concurrent.FastThreadLocalRunnable#run() +│ └── org.jboss.threads.ThreadLocalResettingRunnable#run() +│ └── org.jboss.threads.DelegatingRunnable#run() +│ └── org.jboss.threads.EnhancedQueueExecutor$ThreadBody#run() +│ └── org.jboss.threads.EnhancedQueueExecutor$Task#run() +│ └── org.jboss.threads.EnhancedQueueExecutor$Task#doRunWith(java.lang.Runnable,java.lang.Object) +│ └── io.quarkus.vertx.core.runtime.VertxCoreRecorder$14#runWith(java.lang.Runnable,java.lang.Object) +│ └── org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext#run() +│ └── io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext#invokeHandler(int) +│ └── org.jboss.resteasy.reactive.server.handlers.InvocationHandler#handle(org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext) +│ └── org.acme.GreetingResource$quarkusrestinvoker$greeting_709ef95cd764548a2bbac83843a7f4cdd8077016#invoke(java.lang.Object,java.lang.Object[]) +│ └── org.acme.GreetingResource#greeting(java.lang.String) +│ └── org.acme.GreetingService_ClientProxy#greeting(java.lang.String) +│ └── org.acme.GreetingService#greeting(java.lang.String) +│ ├── java.lang.Class#forName(java.lang.String) - [ { "name":"org.acme.Alice" }, { "name":"org.acme.Bob" } ] +│ ├── java.lang.Class#getDeclaredConstructor(java.lang.Class[]) - [ { "name":"org.acme.Alice", "methods":[{"name":"","parameterTypes":[] }] } ] +│ ├── java.lang.reflect.Constructor#newInstance(java.lang.Object[]) - [ { "name":"org.acme.Alice", "methods":[{"name":"","parameterTypes":[] }] } ] +│ ├── java.lang.reflect.Method#invoke(java.lang.Object,java.lang.Object[]) - [ { "name":"org.acme.Alice", "methods":[{"name":"sayMyName","parameterTypes":[] }] } ] +│ └── java.lang.Class#getMethod(java.lang.String,java.lang.Class[]) - [ { "name":"org.acme.Alice", "methods":[{"name":"sayMyName","parameterTypes":[] }] } ] +... +---- + +==== Debugging With GDB + +The native image agent itself is a native executable produced with GraalVM that uses JVMTI to intercept the calls that require native image configuration. +As a last resort, it is possible to debug the native image agent with GDB, +see link:https://github.com/oracle/graal/blob/master/substratevm/src/com.oracle.svm.agent/README.md[here] +for instructions on how to do that. + [[inspecting-and-debugging]] == Inspecting and Debugging Native Executables This debugging guide provides further details on debugging issues in Quarkus native executables that might arise during development or production. diff --git a/independent-projects/tools/base-codestarts/src/main/resources/codestarts/quarkus/buildtool/maven/base/pom.tpl.qute.xml b/independent-projects/tools/base-codestarts/src/main/resources/codestarts/quarkus/buildtool/maven/base/pom.tpl.qute.xml index ac046389bab46..511b172afc41b 100644 --- a/independent-projects/tools/base-codestarts/src/main/resources/codestarts/quarkus/buildtool/maven/base/pom.tpl.qute.xml +++ b/independent-projects/tools/base-codestarts/src/main/resources/codestarts/quarkus/buildtool/maven/base/pom.tpl.qute.xml @@ -140,6 +140,9 @@ build generate-code generate-code-tests + {#if generate-native} + native-image-agent + {/if} diff --git a/independent-projects/tools/devtools-testing/src/test/resources/__snapshots__/QuarkusCodestartGenerationTest/generateDefault/pom.xml b/independent-projects/tools/devtools-testing/src/test/resources/__snapshots__/QuarkusCodestartGenerationTest/generateDefault/pom.xml index bf5dd9b3afc09..5eb35bfd30a83 100644 --- a/independent-projects/tools/devtools-testing/src/test/resources/__snapshots__/QuarkusCodestartGenerationTest/generateDefault/pom.xml +++ b/independent-projects/tools/devtools-testing/src/test/resources/__snapshots__/QuarkusCodestartGenerationTest/generateDefault/pom.xml @@ -54,6 +54,7 @@ build generate-code generate-code-tests + native-image-agent diff --git a/independent-projects/tools/devtools-testing/src/test/resources/__snapshots__/QuarkusCodestartGenerationTest/generateMavenWithCustomDep/pom.xml b/independent-projects/tools/devtools-testing/src/test/resources/__snapshots__/QuarkusCodestartGenerationTest/generateMavenWithCustomDep/pom.xml index 78a1232f844a2..4479635a0f786 100644 --- a/independent-projects/tools/devtools-testing/src/test/resources/__snapshots__/QuarkusCodestartGenerationTest/generateMavenWithCustomDep/pom.xml +++ b/independent-projects/tools/devtools-testing/src/test/resources/__snapshots__/QuarkusCodestartGenerationTest/generateMavenWithCustomDep/pom.xml @@ -69,6 +69,7 @@ build generate-code generate-code-tests + native-image-agent diff --git a/integration-tests/maven/src/test/java/io/quarkus/maven/it/NativeAgentIT.java b/integration-tests/maven/src/test/java/io/quarkus/maven/it/NativeAgentIT.java new file mode 100644 index 0000000000000..1e39c866fd9f1 --- /dev/null +++ b/integration-tests/maven/src/test/java/io/quarkus/maven/it/NativeAgentIT.java @@ -0,0 +1,33 @@ +package io.quarkus.maven.it; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.apache.maven.shared.invoker.MavenInvocationException; +import org.junit.jupiter.api.Test; + +import io.quarkus.maven.it.verifier.MavenProcessInvocationResult; +import io.quarkus.maven.it.verifier.RunningInvoker; + +@EnableForNative +public class NativeAgentIT extends MojoTestBase { + + @Test + public void testRunIntegrationTests() throws MavenInvocationException, IOException, InterruptedException { + final File testDir = initProject("projects/native-agent-integration"); + final RunningInvoker running = new RunningInvoker(testDir, false); + + MavenProcessInvocationResult runJvmITsWithAgent = running.execute( + List.of("clean", "verify", "-DskipITs=false", "-Dquarkus.test.integration-test-profile=test-with-native-agent"), + Map.of()); + assertThat(runJvmITsWithAgent.getProcess().waitFor()).isZero(); + + MavenProcessInvocationResult runNativeITs = running + .execute(List.of("verify", "-Dnative", "-Dquarkus.native.agent-configuration-apply"), Map.of()); + assertThat(runNativeITs.getProcess().waitFor()).isZero(); + } +} \ No newline at end of file diff --git a/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/pom.xml b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/pom.xml new file mode 100644 index 0000000000000..d89154e0c4c31 --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + org.acme + acme + 1.0-SNAPSHOT + + io.quarkus + quarkus-bom + @project.version@ + @project.version@ + ${compiler-plugin.version} + ${version.surefire.plugin} + ${maven.compiler.source} + ${maven.compiler.target} + UTF-8 + + + + + \${quarkus.platform.group-id} + \${quarkus.platform.artifact-id} + \${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-resteasy + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + + maven-compiler-plugin + \${compiler-plugin.version} + + + -parameters + + + + + maven-surefire-plugin + \${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + \${maven.home} + + + + + maven-failsafe-plugin + \${surefire-plugin.version} + + + + integration-test + verify + + + + \${project.build.directory}/\${project.build.finalName}-runner + org.jboss.logmanager.LogManager + \${maven.home} + + + + + + + io.quarkus + quarkus-maven-plugin + \${quarkus-plugin.version} + + + + build + generate-code + generate-code-tests + native-image-agent + + + + + + + + + native + + + native + + + + native + + + + diff --git a/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/Alice.java b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/Alice.java new file mode 100644 index 0000000000000..af279cc89a22c --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/Alice.java @@ -0,0 +1,9 @@ +package org.acme; + +public class Alice +{ + public String sayMyName() + { + return "Alice"; + } +} diff --git a/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/Carol.java b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/Carol.java new file mode 100644 index 0000000000000..fa3672ee85b25 --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/Carol.java @@ -0,0 +1,12 @@ +package org.acme; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class Carol +{ + public String sayMyName() + { + return "Carol"; + } +} diff --git a/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/GreetingResource.java b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/GreetingResource.java new file mode 100644 index 0000000000000..87516b9396749 --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/GreetingResource.java @@ -0,0 +1,26 @@ +package org.acme; + +import io.quarkus.logging.Log; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/hello") +public class GreetingResource +{ + @Inject + GreetingService service; + + @GET + @Produces(MediaType.TEXT_PLAIN) + @Path("/{name}") + // Add @PathParam to avoid getting an empty name + public String greeting(@PathParam("name") String name) + { + Log.infof("Call greeting service with %s", name); + return service.greeting(name); + } +} diff --git a/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/GreetingService.java b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/GreetingService.java new file mode 100644 index 0000000000000..9c75f9df1eef5 --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/main/java/org/acme/GreetingService.java @@ -0,0 +1,28 @@ +package org.acme; + +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; + +import java.lang.reflect.Method; + +@ApplicationScoped +public class GreetingService +{ + public String greeting(String name) + { + try + { + final Class clazz = Class.forName("org.acme." + name); + final Method method = clazz.getMethod("sayMyName"); + final Object obj = clazz.getDeclaredConstructor().newInstance(); + final Object result = method.invoke(obj); + return "Hello " + result; + } + catch (Exception e) + { + Log.debugf(e, "Unable to create a greeting"); + throw new NotFoundException("Unknown name: " + name); + } + } +} diff --git a/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/test/java/org/acme/GreetingResourceIT.java b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/test/java/org/acme/GreetingResourceIT.java new file mode 100644 index 0000000000000..26bc65acea02b --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/test/java/org/acme/GreetingResourceIT.java @@ -0,0 +1,8 @@ +package org.acme; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class GreetingResourceIT extends GreetingResourceTest { + // Execute the same tests but in packaged mode. +} diff --git a/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/test/java/org/acme/GreetingResourceTest.java b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/test/java/org/acme/GreetingResourceTest.java new file mode 100644 index 0000000000000..2f0e3284d3d71 --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/native-agent-integration/src/test/java/org/acme/GreetingResourceTest.java @@ -0,0 +1,31 @@ +package org.acme; + +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + +@QuarkusTest +public class GreetingResourceTest +{ + @Test + public void testKnownName() + { + given() + .when().get("/hello/Alice") + .then() + .statusCode(200) + .body(is("Hello Alice")); + } + + @Test + public void testUnknownName() + { + given() + .when().get("/hello/Bob") + .then() + .statusCode(404) + .body(is("")); + } +} \ No newline at end of file diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/DefaultDockerContainerLauncher.java b/test-framework/common/src/main/java/io/quarkus/test/common/DefaultDockerContainerLauncher.java index 20fc7ed3a6c9d..2d01ef727fd4a 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/DefaultDockerContainerLauncher.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/DefaultDockerContainerLauncher.java @@ -16,6 +16,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -24,7 +25,9 @@ import org.apache.commons.lang3.RandomStringUtils; import org.jboss.logging.Logger; +import io.quarkus.deployment.pkg.steps.NativeImageBuildLocalContainerRunner; import io.quarkus.deployment.util.ContainerRuntimeUtil; +import io.quarkus.deployment.util.ContainerRuntimeUtil.ContainerRuntime; import io.quarkus.test.common.http.TestHTTPResourceManager; import io.smallrye.config.common.utils.StringUtil; @@ -50,6 +53,8 @@ public class DefaultDockerContainerLauncher implements DockerContainerArtifactLa private final String containerName = "quarkus-integration-test-" + RandomStringUtils.random(5, true, false); private String containerRuntimeBinaryName; private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + private Optional entryPoint; + private List programArgs; @Override public void init(DockerContainerArtifactLauncher.DockerInitContext initContext) { @@ -65,6 +70,8 @@ public void init(DockerContainerArtifactLauncher.DockerInitContext initContext) this.additionalExposedPorts = initContext.additionalExposedPorts(); this.volumeMounts = initContext.volumeMounts(); this.labels = initContext.labels(); + this.entryPoint = initContext.entryPoint(); + this.programArgs = initContext.programArgs(); } @Override @@ -75,7 +82,8 @@ public LaunchResult runToCompletion(String[] args) { @Override public void start() throws IOException { - containerRuntimeBinaryName = determineBinary(); + final ContainerRuntime containerRuntime = ContainerRuntimeUtil.detectContainerRuntime(); + containerRuntimeBinaryName = containerRuntime.getExecutableName(); if (pullRequired) { log.infof("Pulling container image '%s'", containerImage); @@ -109,17 +117,23 @@ public void start() throws IOException { args.add(containerName); args.add("-i"); // Interactive, write logs to stdout args.add("--rm"); + + args.addAll(NativeImageBuildLocalContainerRunner.getVolumeAccessArguments(containerRuntime)); + args.add("-p"); args.add(httpPort + ":" + httpPort); args.add("-p"); args.add(httpsPort + ":" + httpsPort); + if (entryPoint.isPresent()) { + args.add("--entrypoint"); + args.add(entryPoint.get()); + } for (Map.Entry entry : additionalExposedPorts.entrySet()) { args.add("-p"); args.add(entry.getKey() + ":" + entry.getValue()); } for (Map.Entry entry : volumeMounts.entrySet()) { - args.add("-v"); - args.add(entry.getKey() + ":" + entry.getValue()); + NativeImageBuildLocalContainerRunner.addVolumeParameter(entry.getKey(), entry.getValue(), args, containerRuntime); } // if the dev services resulted in creating a dedicated network, then use it if (devServicesLaunchResult.networkId() != null) { @@ -151,6 +165,7 @@ public void start() throws IOException { args.add(e.getKey() + "=" + e.getValue()); } args.add(containerImage); + args.addAll(programArgs); final Path logFile = PropertyTestUtil.getLogFilePath(); try { @@ -176,16 +191,14 @@ public void start() throws IOException { waitTimeSeconds, logFile); isSsl = result.isSsl(); } else { + log.info("Wait for server to start by capturing listening data..."); final ListeningAddress result = waitForCapturedListeningData(containerProcess, logFile, waitTimeSeconds); + log.infof("Server started on port %s", result.getPort()); updateConfigForPort(result.getPort()); isSsl = result.isSsl(); } } - private String determineBinary() { - return ContainerRuntimeUtil.detectContainerRuntime().getExecutableName(); - } - private int getRandomPort() throws IOException { try (ServerSocket socket = new ServerSocket(0)) { return socket.getLocalPort(); @@ -217,14 +230,17 @@ private String convertPropertyToEnvVar(String property) { @Override public void close() { + log.info("Close the container"); try { final Process dockerStopProcess = new ProcessBuilder(containerRuntimeBinaryName, "stop", containerName) .redirectError(DISCARD) .redirectOutput(DISCARD).start(); + log.debug("Wait for container to stop"); dockerStopProcess.waitFor(10, TimeUnit.SECONDS); } catch (IOException | InterruptedException e) { log.errorf("Unable to stop container '%s'", containerName); } + log.debug("Container stopped"); executorService.shutdown(); } } diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/DockerContainerArtifactLauncher.java b/test-framework/common/src/main/java/io/quarkus/test/common/DockerContainerArtifactLauncher.java index 525fd1cf44b7b..520bc963c84b8 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/DockerContainerArtifactLauncher.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/DockerContainerArtifactLauncher.java @@ -1,6 +1,8 @@ package io.quarkus.test.common; +import java.util.List; import java.util.Map; +import java.util.Optional; public interface DockerContainerArtifactLauncher extends ArtifactLauncher { @@ -15,5 +17,9 @@ interface DockerInitContext extends InitContext { Map labels(); Map volumeMounts(); + + Optional entryPoint(); + + List programArgs(); } } diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusIntegrationTestExtension.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusIntegrationTestExtension.java index ae83728df626c..3f7680375d4a6 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusIntegrationTestExtension.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusIntegrationTestExtension.java @@ -275,13 +275,14 @@ public void close() throws Throwable { Config config = LauncherUtil.installAndGetSomeConfig(); Duration waitDuration = TestConfigUtil.waitTimeValue(config); String target = TestConfigUtil.runTarget(config); + String testProfile = TestConfigUtil.integrationTestProfile(config); // try to execute a run command published by an extension if it exists. We do this so that extensions that have a custom run don't have to create any special artifact type launcher = RunCommandLauncher.tryLauncher(devServicesLaunchResult.getCuratedApplication().getQuarkusBootstrap(), target, waitDuration); if (launcher == null) { ServiceLoader loader = ServiceLoader.load(ArtifactLauncherProvider.class); for (ArtifactLauncherProvider launcherProvider : loader) { - if (launcherProvider.supportsArtifactType(artifactType)) { + if (launcherProvider.supportsArtifactType(artifactType, testProfile)) { launcher = launcherProvider.create( new DefaultArtifactLauncherCreateContext(quarkusArtifactProperties, context, requiredTestClass, diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusMainIntegrationTestExtension.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusMainIntegrationTestExtension.java index 023eb5680c140..deb98903ab1ad 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusMainIntegrationTestExtension.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/QuarkusMainIntegrationTestExtension.java @@ -17,6 +17,7 @@ import java.util.Properties; import java.util.ServiceLoader; +import org.eclipse.microprofile.config.Config; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; @@ -27,6 +28,8 @@ import io.quarkus.runtime.logging.JBossVersion; import io.quarkus.test.common.ArtifactLauncher; +import io.quarkus.test.common.LauncherUtil; +import io.quarkus.test.common.TestConfigUtil; import io.quarkus.test.common.TestResourceManager; import io.quarkus.test.junit.launcher.ArtifactLauncherProvider; import io.quarkus.test.junit.main.Launch; @@ -154,10 +157,13 @@ private ArtifactLauncher.LaunchResult doProcessStart(ExtensionContext context, S testResourceManager.inject(context.getRequiredTestInstance()); + Config config = LauncherUtil.installAndGetSomeConfig(); + String testProfile = TestConfigUtil.integrationTestProfile(config); + ArtifactLauncher launcher = null; ServiceLoader loader = ServiceLoader.load(ArtifactLauncherProvider.class); for (ArtifactLauncherProvider launcherProvider : loader) { - if (launcherProvider.supportsArtifactType(artifactType)) { + if (launcherProvider.supportsArtifactType(artifactType, testProfile)) { launcher = launcherProvider.create( new DefaultArtifactLauncherCreateContext(quarkusArtifactProperties, context, requiredTestClass, devServicesLaunchResult)); diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/ArtifactLauncherProvider.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/ArtifactLauncherProvider.java index 7a8cca781b46a..c63bd1b713b5f 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/ArtifactLauncherProvider.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/ArtifactLauncherProvider.java @@ -11,7 +11,7 @@ public interface ArtifactLauncherProvider { * Determines whether this provider support the artifact type * */ - boolean supportsArtifactType(String type); + boolean supportsArtifactType(String type, String testProfile); /** * Returns an instance of {@link ArtifactLauncher} on which the {@code init} method has been called diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/DockerContainerLauncherProvider.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/DockerContainerLauncherProvider.java index 68e5d02992776..0ee3a8e35628b 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/DockerContainerLauncherProvider.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/DockerContainerLauncherProvider.java @@ -1,18 +1,24 @@ package io.quarkus.test.junit.launcher; +import static io.quarkus.deployment.pkg.NativeConfig.DEFAULT_MANDREL_BUILDER_IMAGE; import static io.quarkus.test.junit.ArtifactTypeUtil.isContainer; +import static io.quarkus.test.junit.ArtifactTypeUtil.isJar; import static io.quarkus.test.junit.IntegrationTestUtil.DEFAULT_HTTPS_PORT; import static io.quarkus.test.junit.IntegrationTestUtil.DEFAULT_PORT; import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.OptionalInt; import java.util.ServiceLoader; +import io.quarkus.deployment.util.FileUtil; import io.quarkus.test.common.ArtifactLauncher; import io.quarkus.test.common.DefaultDockerContainerLauncher; import io.quarkus.test.common.DockerContainerArtifactLauncher; @@ -23,8 +29,8 @@ public class DockerContainerLauncherProvider implements ArtifactLauncherProvider { @Override - public boolean supportsArtifactType(String type) { - return isContainer(type); + public boolean supportsArtifactType(String type, String testProfile) { + return isContainer(type) || (isJar(type) && "test-with-native-agent".equals(testProfile)); } @Override @@ -42,25 +48,81 @@ public DockerContainerArtifactLauncher create(CreateContext context) { launcher = new DefaultDockerContainerLauncher(); } SmallRyeConfig config = (SmallRyeConfig) LauncherUtil.installAndGetSomeConfig(); - launcher.init(new DefaultDockerInitContext( - config.getValue("quarkus.http.test-port", OptionalInt.class).orElse(DEFAULT_PORT), - config.getValue("quarkus.http.test-ssl-port", OptionalInt.class).orElse(DEFAULT_HTTPS_PORT), - TestConfigUtil.waitTimeValue(config), - TestConfigUtil.integrationTestProfile(config), - TestConfigUtil.argLineValue(config), - TestConfigUtil.env(config), - context.devServicesLaunchResult(), - containerImage, - pullRequired, - additionalExposedPorts(config), - labels(config), - volumeMounts(config))); + launcherInit(context, launcher, config, containerImage, pullRequired, Optional.empty(), volumeMounts(config), + Collections.emptyList()); return launcher; } else { - throw new IllegalStateException("The container image to be launched could not be determined"); + // Running quarkus integration tests with a native image agent, + // which can be achieved with a specific test profile name, + // involves having Quarkus run with the java process inside the default Mandrel builder container image. + // This block achieves this by swapping the entry point to be the java executable, + // adding a volume mapping pointing to the build output directory, + // and then instructing the java process to run the run jar, + // along with the native image agent arguments and any other additional parameters. + SmallRyeConfig config = (SmallRyeConfig) LauncherUtil.installAndGetSomeConfig(); + String testProfile = TestConfigUtil.integrationTestProfile(config); + + if ("test-with-native-agent".equals(testProfile)) { + DockerContainerArtifactLauncher launcher = new DefaultDockerContainerLauncher(); + Optional entryPoint = Optional.of("java"); + Map volumeMounts = new HashMap<>(volumeMounts(config)); + volumeMounts.put(context.buildOutputDirectory().toString(), "/project"); + containerImage = DEFAULT_MANDREL_BUILDER_IMAGE; + + List programArgs = new ArrayList<>(); + addNativeAgentProgramArgs(programArgs, context); + + launcherInit(context, launcher, config, containerImage, pullRequired, entryPoint, volumeMounts, programArgs); + return launcher; + } else { + throw new IllegalStateException("The container image to be launched could not be determined"); + } } } + private void launcherInit(CreateContext context, DockerContainerArtifactLauncher launcher, SmallRyeConfig config, + String containerImage, boolean pullRequired, Optional entryPoint, Map volumeMounts, + List programArgs) { + launcher.init(new DefaultDockerInitContext( + config.getValue("quarkus.http.test-port", OptionalInt.class).orElse(DEFAULT_PORT), + config.getValue("quarkus.http.test-ssl-port", OptionalInt.class).orElse(DEFAULT_HTTPS_PORT), + TestConfigUtil.waitTimeValue(config), + TestConfigUtil.integrationTestProfile(config), + TestConfigUtil.argLineValue(config), + TestConfigUtil.env(config), + context.devServicesLaunchResult(), + containerImage, + pullRequired, + additionalExposedPorts(config), + labels(config), + volumeMounts, + entryPoint, + programArgs)); + } + + private void addNativeAgentProgramArgs(List programArgs, CreateContext context) { + final String outputPropertyName = System.getProperty("quarkus.test.native.agent.output.property.name", + "config-output-dir"); + final String outputPropertyValue = System.getProperty("quarkus.test.native.agent.output.property.value", + "native-image-agent-base-config"); + final String agentAdditionalArgs = System.getProperty("quarkus.test.native.agent.additional.args", ""); + + final String accessFilter = "access-filter-file=quarkus-access-filter.json"; + final String callerFilter = "caller-filter-file=quarkus-caller-filter.json"; + + final String output = String.format( + "%s=%s", outputPropertyName, outputPropertyValue); + + String agentLibArg = String.format( + "-agentlib:native-image-agent=%s,%s,%s,%s", accessFilter, callerFilter, output, agentAdditionalArgs); + + programArgs.add(agentLibArg); + + programArgs.add("-jar"); + final String jarPath = FileUtil.translateToVolumePath(context.quarkusArtifactProperties().getProperty("path")); + programArgs.add(jarPath); + } + private Map additionalExposedPorts(SmallRyeConfig config) { try { return config.getValues("quarkus.test.container.additional-exposed-ports", Integer.class, Integer.class); @@ -90,6 +152,8 @@ static class DefaultDockerInitContext extends DefaultInitContextBase private final String containerImage; private final boolean pullRequired; private final Map additionalExposedPorts; + private final Optional entryPoint; + private final List programArgs; private Map labels; private Map volumeMounts; @@ -99,13 +163,15 @@ public DefaultDockerInitContext(int httpPort, int httpsPort, Duration waitTime, String containerImage, boolean pullRequired, Map additionalExposedPorts, Map labels, - Map volumeMounts) { + Map volumeMounts, Optional entryPoint, List programArgs) { super(httpPort, httpsPort, waitTime, testProfile, argLine, env, devServicesLaunchResult); this.containerImage = containerImage; this.pullRequired = pullRequired; this.additionalExposedPorts = additionalExposedPorts; this.labels = labels; this.volumeMounts = volumeMounts; + this.entryPoint = entryPoint; + this.programArgs = programArgs; } @Override @@ -133,5 +199,14 @@ public Map volumeMounts() { return volumeMounts; } + @Override + public Optional entryPoint() { + return entryPoint; + } + + @Override + public List programArgs() { + return programArgs; + } } } diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/JarLauncherProvider.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/JarLauncherProvider.java index ce2efc03d62a9..ef360c7500053 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/JarLauncherProvider.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/JarLauncherProvider.java @@ -23,7 +23,7 @@ public class JarLauncherProvider implements ArtifactLauncherProvider { @Override - public boolean supportsArtifactType(String type) { + public boolean supportsArtifactType(String type, String testProfile) { return isJar(type); } diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/NativeImageLauncherProvider.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/NativeImageLauncherProvider.java index 1ade26e674a59..e33465517c0c2 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/NativeImageLauncherProvider.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/launcher/NativeImageLauncherProvider.java @@ -21,7 +21,7 @@ public class NativeImageLauncherProvider implements ArtifactLauncherProvider { @Override - public boolean supportsArtifactType(String type) { + public boolean supportsArtifactType(String type, String testProfile) { return isNativeBinary(type); } diff --git a/test-framework/maven/src/main/java/io/quarkus/maven/it/verifier/MavenProcessInvoker.java b/test-framework/maven/src/main/java/io/quarkus/maven/it/verifier/MavenProcessInvoker.java index 4c90095151ddd..4a5051076cc57 100644 --- a/test-framework/maven/src/main/java/io/quarkus/maven/it/verifier/MavenProcessInvoker.java +++ b/test-framework/maven/src/main/java/io/quarkus/maven/it/verifier/MavenProcessInvoker.java @@ -56,6 +56,9 @@ public InvocationResult execute(InvocationRequest request) throws MavenInvocatio Commandline cli; try { cli = cliBuilder.build(request); + if (logger != null) { + logger.debug("Running Maven CLI command: " + cli); + } } catch (CommandLineConfigurationException e) { throw new MavenInvocationException("Error configuring command-line. Reason: " + e.getMessage(), e); }