diff --git a/core/deployment/src/main/java/io/quarkus/deployment/CodeGenerator.java b/core/deployment/src/main/java/io/quarkus/deployment/CodeGenerator.java index bcc15ea7ec38f2..b9863409410397 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/CodeGenerator.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/CodeGenerator.java @@ -24,6 +24,7 @@ import io.quarkus.bootstrap.prebuild.CodeGenException; import io.quarkus.deployment.codegen.CodeGenData; import io.quarkus.deployment.configuration.BuildTimeConfigurationReader; +import io.quarkus.deployment.configuration.tracker.ConfigTrackingValueTransformer; import io.quarkus.deployment.dev.DevModeContext; import io.quarkus.deployment.dev.DevModeContext.ModuleInfo; import io.quarkus.maven.dependency.ResolvedDependency; @@ -185,6 +186,43 @@ public static boolean trigger(ClassLoader deploymentClassLoader, }); } + /** + * Initializes an application build time configuration and returns current values of properties + * passed in as {@code originalProperties}. + * + * @param appModel application model + * @param launchMode launch mode + * @param buildSystemProps build system (or project) properties + * @param deploymentClassLoader build classloader + * @param originalProperties properties to read from the initialized configuration + * @return current values of the passed in original properties + */ + public static Properties readCurrentConfigValues(ApplicationModel appModel, String launchMode, + Properties buildSystemProps, + QuarkusClassLoader deploymentClassLoader, Properties originalProperties) { + Config config = null; + try { + config = getConfig(appModel, LaunchMode.valueOf(launchMode), buildSystemProps, deploymentClassLoader); + } catch (CodeGenException e) { + throw new RuntimeException("Failed to load application configuration", e); + } + var valueTransformer = ConfigTrackingValueTransformer.newInstance(config); + final Properties currentValues = new Properties(originalProperties.size()); + for (var originalProp : originalProperties.entrySet()) { + var name = originalProp.getKey().toString(); + var currentValue = config.getConfigValue(name); + final String current = valueTransformer.transform(name, currentValue); + if (!originalProp.getValue().equals(current)) { + log.info("Option " + name + " has changed since the last build from " + + originalProp.getValue() + " to " + current); + } + if (current != null) { + currentValues.put(name, current); + } + } + return currentValues; + } + public static Config getConfig(ApplicationModel appModel, LaunchMode launchMode, Properties buildSystemProps, QuarkusClassLoader deploymentClassLoader) throws CodeGenException { final Map> unavailableConfigServices = getUnavailableConfigServices(appModel.getAppArtifact(), diff --git a/core/deployment/src/main/java/io/quarkus/deployment/configuration/BuildTimeConfigurationReader.java b/core/deployment/src/main/java/io/quarkus/deployment/configuration/BuildTimeConfigurationReader.java index da0354bcdbc787..b2e8e8f9ae54b6 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/configuration/BuildTimeConfigurationReader.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/configuration/BuildTimeConfigurationReader.java @@ -47,6 +47,7 @@ import io.quarkus.deployment.configuration.matching.FieldContainer; import io.quarkus.deployment.configuration.matching.MapContainer; import io.quarkus.deployment.configuration.matching.PatternMapBuilder; +import io.quarkus.deployment.configuration.tracker.ConfigTrackingInterceptor; import io.quarkus.deployment.configuration.type.ArrayOf; import io.quarkus.deployment.configuration.type.CollectionOf; import io.quarkus.deployment.configuration.type.ConverterType; @@ -124,6 +125,8 @@ private static List> collectConfigRoots(ClassLoader classLoader) throws final Set deprecatedProperties; final Set deprecatedRuntimeProperties; + final ConfigTrackingInterceptor buildConfigTracker; + /** * Initializes a new instance with located configuration root classes on the classpath * of a given classloader. @@ -242,6 +245,8 @@ private BuildTimeConfigurationReader(ClassLoader classLoader, final List clazz, @@ -408,11 +413,15 @@ public SmallRyeConfig initConfiguration(LaunchMode launchMode, Properties buildS for (ConfigClassWithPrefix mapping : getBuildTimeVisibleMappings()) { builder.withMapping(mapping.getKlass(), mapping.getPrefix()); } - return builder.build(); + + builder.withInterceptors(buildConfigTracker); + var config = builder.build(); + buildConfigTracker.configure(config); + return config; } public ReadResult readConfiguration(final SmallRyeConfig config) { - return SecretKeys.doUnlocked(() -> new ReadOperation(config).run()); + return SecretKeys.doUnlocked(() -> new ReadOperation(config, buildConfigTracker).run()); } private Set getDeprecatedProperties(Iterable rootDefinitions) { @@ -468,6 +477,7 @@ private void collectDeprecatedConfigItems(ClassMember classMember, Set d final class ReadOperation { final SmallRyeConfig config; + final ConfigTrackingInterceptor buildConfigTracker; final Set processedNames = new HashSet<>(); final Map, Object> objectsByClass = new HashMap<>(); @@ -477,8 +487,9 @@ final class ReadOperation { final Map> convByType = new HashMap<>(); - ReadOperation(final SmallRyeConfig config) { + ReadOperation(final SmallRyeConfig config, ConfigTrackingInterceptor buildConfigTracker) { this.config = config; + this.buildConfigTracker = buildConfigTracker; } ReadResult run() { @@ -684,6 +695,7 @@ ReadResult run() { .setRunTimeMappings(runTimeMappings) .setUnknownBuildProperties(unknownBuildProperties) .setDeprecatedRuntimeProperties(deprecatedRuntimeProperties) + .setBuildConfigTracker(buildConfigTracker) .createReadResult(); } @@ -1151,6 +1163,7 @@ public static final class ReadResult { final Set unknownBuildProperties; final Set deprecatedRuntimeProperties; + final ConfigTrackingInterceptor.ReadOptionsProvider readOptionsProvider; public ReadResult(final Builder builder) { this.objectsByClass = builder.getObjectsByClass(); @@ -1176,6 +1189,8 @@ public ReadResult(final Builder builder) { this.unknownBuildProperties = builder.getUnknownBuildProperties(); this.deprecatedRuntimeProperties = builder.deprecatedRuntimeProperties; + this.readOptionsProvider = builder.buildConfigTracker == null ? null + : builder.buildConfigTracker.getReadOptionsProvider(); } private static Map, RootDefinition> rootsToMap(Builder builder) { @@ -1276,6 +1291,10 @@ public Object requireObjectForClass(Class clazz) { return obj; } + public ConfigTrackingInterceptor.ReadOptionsProvider getReadOptionsProvider() { + return readOptionsProvider; + } + static class Builder { private Map, Object> objectsByClass; private Map allBuildTimeValues; @@ -1292,6 +1311,7 @@ static class Builder { private List runTimeMappings; private Set unknownBuildProperties; private Set deprecatedRuntimeProperties; + private ConfigTrackingInterceptor buildConfigTracker; Map, Object> getObjectsByClass() { return objectsByClass; @@ -1424,6 +1444,11 @@ Builder setDeprecatedRuntimeProperties(Set deprecatedRuntimeProperties) return this; } + Builder setBuildConfigTracker(ConfigTrackingInterceptor buildConfigTracker) { + this.buildConfigTracker = buildConfigTracker; + return this; + } + ReadResult createReadResult() { return new ReadResult(this); } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingConfig.java b/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingConfig.java new file mode 100644 index 00000000000000..6508e33023b89c --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingConfig.java @@ -0,0 +1,103 @@ +package io.quarkus.deployment.configuration.tracker; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.quarkus.util.GlobUtil; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * Configuration options for application build time configuration usage tracking + * and dumping. + */ +@ConfigMapping(prefix = "quarkus.config-tracking") +@ConfigRoot(phase = ConfigPhase.BUILD_TIME) +public interface ConfigTrackingConfig { + + /** + * Whether configuration dumping is enabled + */ + @WithDefault("false") + boolean enabled(); + + /** + * Directory in which the configuration dump should be stored. + * If not configured the {@code .quarkus} directory under the project directory will be used. + */ + Optional directory(); + + /** + * File in which the configuration dump should be stored. If not configured, the {@link #filePrefix} and + * {@link #fileSuffix} will be used to generate the final file name. + * If the configured file path is absolute, the {@link #directory} option will be ignored. Otherwise, + * the path will be considered relative to the {@link #directory}. + */ + Optional file(); + + /** + * File name prefix. This option will be ignored in case {@link #file} is configured. + */ + @WithDefault("quarkus") + String filePrefix(); + + /** + * File name suffix. This option will be ignored in case {@link #file} is configured. + */ + @WithDefault("-config-dump") + String fileSuffix(); + + /** + * A list of config properties that should be excluded from the report. + * GLOB patterns could be used instead of property names. + */ + Optional> exclude(); + + /** + * Translates the value of {@link #exclude} to a list of {@link java.util.regex.Pattern}. + * + * @return list of patterns created from {@link #exclude} + */ + default List getExcludePatterns() { + return toPatterns(exclude()); + } + + /** + * A list of config properties whose values should be hashed in the report. + * The values will be hashed using SHA-512 algorithm. + * GLOB patterns could be used instead of property names. + */ + Optional> hashOptions(); + + /** + * Translates the value of {@link #hashOptions()} to a list of {@link java.util.regex.Pattern}. + * + * @return list of patterns created from {@link #hashOptions()} + */ + default List getHashOptionsPatterns() { + return toPatterns(hashOptions()); + } + + static List toPatterns(Optional> globs) { + if (globs.isEmpty()) { + return List.of(); + } + var list = globs.get(); + final List patterns = new ArrayList<>(list.size()); + for (var s : list) { + patterns.add(Pattern.compile(GlobUtil.toRegexPattern(s))); + } + return patterns; + } + + /** + * Whether to use a {@code ~} as an alias for user home directory in path values + */ + @WithDefault("true") + boolean useUserHomeAliasInPaths(); +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingInterceptor.java b/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingInterceptor.java new file mode 100644 index 00000000000000..3e5f92866492ad --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingInterceptor.java @@ -0,0 +1,89 @@ +package io.quarkus.deployment.configuration.tracker; + +import static io.smallrye.config.SecretKeys.doLocked; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import jakarta.annotation.Priority; + +import org.eclipse.microprofile.config.Config; + +import io.quarkus.deployment.configuration.BuildTimeConfigurationReader; +import io.quarkus.runtime.LaunchMode; +import io.smallrye.config.ConfigSourceInterceptor; +import io.smallrye.config.ConfigSourceInterceptorContext; +import io.smallrye.config.ConfigValue; +import io.smallrye.config.Priorities; + +/** + * Build configuration interceptor that records all the configuration options + * and their values that are read during the build. + */ +@Priority(Priorities.APPLICATION) +public class ConfigTrackingInterceptor implements ConfigSourceInterceptor { + + /** + * A writer that persists collected configuration options and their values to a file + */ + public interface ConfigurationWriter { + void write(ConfigTrackingConfig config, BuildTimeConfigurationReader.ReadResult configReadResult, + LaunchMode launchMode, Path buildDirectory); + } + + /** + * Provides an immutable map of options that were read during the build. + */ + public interface ReadOptionsProvider { + + /** + * An immutable map of options read during the build. + * + * @return immutable map of options read during the build + */ + Map getReadOptions(); + } + + private boolean enabled; + // it's a String value map to be able to represent null (not configured) values + private Map readOptions = Map.of(); + private final ReadOptionsProvider readOptionsProvider = new ReadOptionsProvider() { + @Override + public Map getReadOptions() { + return Collections.unmodifiableMap(readOptions); + } + }; + + /** + * Initializes the configuration tracker + * + * @param config configuration instance + */ + public void configure(Config config) { + enabled = config.getValue("quarkus.config-tracking.enabled", boolean.class); + if (enabled) { + readOptions = new ConcurrentHashMap<>(); + } + } + + @Override + public ConfigValue getValue(ConfigSourceInterceptorContext context, String name) { + if (!enabled) { + return context.proceed(name); + } + final ConfigValue configValue = doLocked(() -> context.proceed(name)); + readOptions.put(name, ConfigTrackingValueTransformer.asString(configValue)); + return configValue; + } + + /** + * Read options orvipder. + * + * @return read options provider + */ + public ReadOptionsProvider getReadOptionsProvider() { + return readOptionsProvider; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingValueTransformer.java b/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingValueTransformer.java new file mode 100644 index 00000000000000..7c098a6b722cfc --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingValueTransformer.java @@ -0,0 +1,129 @@ +package io.quarkus.deployment.configuration.tracker; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.StringJoiner; +import java.util.regex.Pattern; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigValue; + +import io.quarkus.bootstrap.util.PropertyUtils; +import io.smallrye.config.SmallRyeConfig; + +/** + * Transforms configuration values before they are written to a file + */ +public class ConfigTrackingValueTransformer { + + private static final String NOT_CONFIGURED = "quarkus.config-tracking:not-configured"; + private static final String PATH_ELEMENT_SEPARATOR = "/"; + private static final String USER_HOME_DIR_ALIAS = "~"; + + private static volatile MessageDigest SHA512; + + private static MessageDigest getSHA512() { + if (SHA512 == null) { + try { + SHA512 = MessageDigest.getInstance("SHA-512"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + return SHA512; + } + + public static ConfigTrackingValueTransformer newInstance(Config config) { + return new ConfigTrackingValueTransformer( + config.unwrap(SmallRyeConfig.class).getConfigMapping(ConfigTrackingConfig.class)); + } + + public static ConfigTrackingValueTransformer newInstance(ConfigTrackingConfig config) { + return new ConfigTrackingValueTransformer(config); + } + + /** + * Returns a non-null string value for a given {@link org.eclipse.microprofile.config.ConfigValue} instance. + * + * @param value configuration value + * @return non-null string value for a given {@link org.eclipse.microprofile.config.ConfigValue} instance + */ + public static String asString(ConfigValue value) { + return value == null ? NOT_CONFIGURED : value.getValue(); + } + + private final String userHomeDir; + private final List hashOptionsPatterns; + + private ConfigTrackingValueTransformer(ConfigTrackingConfig config) { + userHomeDir = config.useUserHomeAliasInPaths() ? PropertyUtils.getUserHome() : null; + hashOptionsPatterns = config.getHashOptionsPatterns(); + } + + /** + * Returns a string value that can be persisted to file. + * + * @param name option name + * @param value configuration value + * @return string value that can be persisted to file + */ + public String transform(String name, ConfigValue value) { + return value == null ? NOT_CONFIGURED : transform(name, value.getValue()); + } + + /** + * Returns a string value that can be persisted to file. + * + * @param name option name + * @param original configuration value + * @return string value that can be persisted to file + */ + public String transform(String name, String original) { + if (original == null) { + return NOT_CONFIGURED; + } + + for (Pattern pattern : hashOptionsPatterns) { + if (pattern.matcher(name).matches()) { + return sha512(original); + } + } + + // replace user home path with an alias + if (userHomeDir != null && original.startsWith(userHomeDir)) { + var relativePath = original.substring(userHomeDir.length()); + if (relativePath.isEmpty()) { + return USER_HOME_DIR_ALIAS; + } + if (File.separator.equals(PATH_ELEMENT_SEPARATOR)) { + return USER_HOME_DIR_ALIAS + relativePath; + } + final StringJoiner joiner = new StringJoiner("/"); + joiner.add(USER_HOME_DIR_ALIAS); + var path = Path.of(relativePath); + for (int i = 0; i < path.getNameCount(); ++i) { + joiner.add(path.getName(i).toString()); + } + return joiner.toString(); + } + + return original; + } + + public static String sha512(String value) { + return sha512(value.getBytes(StandardCharsets.UTF_8)); + } + + public static String sha512(byte[] value) { + final byte[] digest = getSHA512().digest(value); + final StringBuilder sb = new StringBuilder(40); + for (int i = 0; i < digest.length; ++i) { + sb.append(Integer.toHexString((digest[i] & 0xFF) | 0x100).substring(1, 3)); + } + return sb.toString(); + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingWriter.java b/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingWriter.java new file mode 100644 index 00000000000000..20c51265c5032c --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/configuration/tracker/ConfigTrackingWriter.java @@ -0,0 +1,110 @@ +package io.quarkus.deployment.configuration.tracker; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import io.quarkus.deployment.configuration.BuildTimeConfigurationReader; +import io.quarkus.runtime.LaunchMode; + +public class ConfigTrackingWriter { + + /** + * Checks whether a given configuration option matches at least one of the patterns. + * If the list of patterns is empty, the method will return false. + * + * @param name configuration option name + * @param patterns a list of name patterns + * @return true in case the option name matches at least one of the patterns, otherwise - false + */ + private static boolean matches(String name, List patterns) { + for (var pattern : patterns) { + if (pattern.matcher(name).matches()) { + return true; + } + } + return false; + } + + /** + * Configuration writer that will persist collected configuration options and their values + * to a file. + */ + public static void write(Map readOptions, ConfigTrackingConfig config, + BuildTimeConfigurationReader.ReadResult configReadResult, + LaunchMode launchMode, Path buildDirectory) { + if (!config.enabled()) { + return; + } + + Path file = config.file().orElse(null); + if (file == null) { + final Path dir = config.directory().orElseGet(() -> (buildDirectory.getParent() == null + ? buildDirectory + : buildDirectory.getParent()).resolve(".quarkus")); + file = dir + .resolve(config.filePrefix() + "-" + launchMode.getDefaultProfile() + config.fileSuffix()); + } else if (!file.isAbsolute()) { + file = config.directory().orElse(buildDirectory).resolve(file); + } + + if (file.getParent() != null) { + try { + Files.createDirectories(file.getParent()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + final List excludePatterns = config.getExcludePatterns(); + final ConfigTrackingValueTransformer valueTransformer = ConfigTrackingValueTransformer.newInstance(config); + + final Map allBuildTimeValues = configReadResult.getAllBuildTimeValues(); + final Map buildTimeRuntimeValues = configReadResult.getBuildTimeRunTimeValues(); + try (BufferedWriter writer = Files.newBufferedWriter(file)) { + final List names = new ArrayList<>(readOptions.size()); + for (var name : readOptions.keySet()) { + if ((allBuildTimeValues.containsKey(name) || buildTimeRuntimeValues.containsKey(name)) + && !matches(name, excludePatterns)) { + names.add(name); + } + } + Collections.sort(names); + for (String name : names) { + var value = valueTransformer.transform(name, readOptions.get(name)); + write(writer, name, value); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Writes a config option with its value to the target writer, + * possibly applying some transformations, such as character escaping + * prior to writing. + * + * @param writer target writer + * @param name option name + * @param value option value + * @throws IOException in case of a failure + */ + public static void write(Writer writer, String name, String value) throws IOException { + if (value != null) { + // escape the backslash before persisting + value = value.replace("\\", "\\\\"); + writer.write(name); + writer.write("="); + writer.write(value); + writer.write(System.lineSeparator()); + } + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java index 48bd549b6edd66..1b118239d4991b 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/steps/ConfigGenerationBuildStep.java @@ -10,6 +10,7 @@ import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toSet; +import java.io.Closeable; import java.io.IOException; import java.lang.reflect.Modifier; import java.net.URI; @@ -51,6 +52,7 @@ import io.quarkus.deployment.builditem.HotDeploymentWatchedFileBuildItem; import io.quarkus.deployment.builditem.LaunchModeBuildItem; import io.quarkus.deployment.builditem.LiveReloadBuildItem; +import io.quarkus.deployment.builditem.QuarkusBuildCloseablesBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigBuilderBuildItem; import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem; import io.quarkus.deployment.builditem.StaticInitConfigBuilderBuildItem; @@ -60,6 +62,10 @@ import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.configuration.BuildTimeConfigurationReader; import io.quarkus.deployment.configuration.RunTimeConfigurationGenerator; +import io.quarkus.deployment.configuration.tracker.ConfigTrackingConfig; +import io.quarkus.deployment.configuration.tracker.ConfigTrackingWriter; +import io.quarkus.deployment.pkg.builditem.ArtifactResultBuildItem; +import io.quarkus.deployment.pkg.builditem.BuildSystemTargetBuildItem; import io.quarkus.deployment.pkg.steps.NativeOrNativeSourcesBuild; import io.quarkus.deployment.recording.RecorderContext; import io.quarkus.gizmo.ClassCreator; @@ -480,6 +486,29 @@ void warnDifferentProfileUsedBetweenBuildAndRunTime(ConfigRecorder configRecorde configRecorder.handleNativeProfileChange(config.getProfiles()); } + @BuildStep(onlyIf = IsNormal.class) + void persistReadConfigOptions(BuildProducer dummy, + QuarkusBuildCloseablesBuildItem closeables, + LaunchModeBuildItem launchModeBuildItem, + BuildSystemTargetBuildItem buildSystemTargetBuildItem, + ConfigurationBuildItem configBuildItem, + ConfigTrackingConfig configTrackingConfig) { + var readOptionsProvider = configBuildItem.getReadResult().getReadOptionsProvider(); + if (readOptionsProvider != null) { + closeables.add(new Closeable() { + @Override + public void close() throws IOException { + ConfigTrackingWriter.write( + readOptionsProvider.getReadOptions(), + configTrackingConfig, + configBuildItem.getReadResult(), + launchModeBuildItem.getLaunchMode(), + buildSystemTargetBuildItem.getOutputDirectory()); + } + }); + } + } + private String appendProfileToFilename(Path path, String activeProfile) { String pathWithoutExtension = getPathWithoutExtension(path); return String.format("%s-%s.%s", pathWithoutExtension, activeProfile, getFileExtension(path)); diff --git a/core/runtime/src/main/java/io/quarkus/runtime/util/HashUtil.java b/core/runtime/src/main/java/io/quarkus/runtime/util/HashUtil.java index 4b42a4780ef1ae..e70a7c6d65ddc8 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/util/HashUtil.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/util/HashUtil.java @@ -6,6 +6,20 @@ public final class HashUtil { + private static MessageDigest getMessageDigest(String alg) { + try { + return MessageDigest.getInstance(alg); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException(e); + } + } + + private static void toHex(byte[] digest, StringBuilder sb) { + for (int i = 0; i < digest.length; ++i) { + sb.append(Integer.toHexString((digest[i] & 0xFF) | 0x100).substring(1, 3)); + } + } + private HashUtil() { } @@ -14,17 +28,10 @@ public static String sha1(String value) { } public static String sha1(byte[] value) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - byte[] digest = md.digest(value); - StringBuilder sb = new StringBuilder(40); - for (int i = 0; i < digest.length; ++i) { - sb.append(Integer.toHexString((digest[i] & 0xFF) | 0x100).substring(1, 3)); - } - return sb.toString(); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException(e); - } + final byte[] digest = getMessageDigest("SHA-1").digest(value); + var sb = new StringBuilder(40); + toHex(digest, sb); + return sb.toString(); } public static String sha256(String value) { @@ -32,16 +39,20 @@ public static String sha256(String value) { } public static String sha256(byte[] value) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] digest = md.digest(value); - StringBuilder sb = new StringBuilder(40); - for (int i = 0; i < digest.length; ++i) { - sb.append(Integer.toHexString((digest[i] & 0xFF) | 0x100).substring(1, 3)); - } - return sb.toString(); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException(e); - } + final byte[] digest = getMessageDigest("SHA-256").digest(value); + var sb = new StringBuilder(40); + toHex(digest, sb); + return sb.toString(); + } + + public static String sha512(String value) { + return sha512(value.getBytes(StandardCharsets.UTF_8)); + } + + public static String sha512(byte[] value) { + final byte[] digest = getMessageDigest("SHA-512").digest(value); + var sb = new StringBuilder(128); + toHex(digest, sb); + return sb.toString(); } } diff --git a/devtools/maven/src/main/java/io/quarkus/maven/BuildMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/BuildMojo.java index bbe090b33678ca..0c7146ddb16787 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/BuildMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/BuildMojo.java @@ -9,9 +9,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; -import java.util.stream.Stream; import org.apache.maven.artifact.Artifact; import org.apache.maven.plugin.MojoExecutionException; @@ -21,7 +19,6 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; -import org.apache.maven.project.MavenProject; import org.apache.maven.project.MavenProjectHelper; import org.eclipse.aether.repository.RemoteRepository; @@ -38,10 +35,6 @@ @Mojo(name = "build", defaultPhase = LifecyclePhase.PACKAGE, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, threadSafe = true) public class BuildMojo extends QuarkusBootstrapMojo { - static final String PACKAGE_TYPE_PROP = "quarkus.package.type"; - static final String NATIVE_PROFILE_NAME = "native"; - static final String NATIVE_PACKAGE_TYPE = "native"; - @Component MavenProjectHelper projectHelper; @@ -125,14 +118,7 @@ protected void doExecute() throws MojoExecutionException { // Essentially what this does is to enable the native package type even if a different package type is set // in application properties. This is done to preserve what users expect to happen when // they execute "mvn package -Dnative" even if quarkus.package.type has been set in application.properties - if (!System.getProperties().containsKey(PACKAGE_TYPE_PROP) - && isNativeProfileEnabled(mavenProject())) { - Object packageTypeProp = mavenProject().getProperties().get(PACKAGE_TYPE_PROP); - String packageType = NATIVE_PACKAGE_TYPE; - if (packageTypeProp != null) { - packageType = packageTypeProp.toString(); - } - System.setProperty(PACKAGE_TYPE_PROP, packageType); + if (!setPackageTypeSystemPropertyIfNativeProfileEnabled()) { propertiesToClear.add(PACKAGE_TYPE_PROP); } @@ -193,17 +179,6 @@ && isNativeProfileEnabled(mavenProject())) { } } - 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); - if (activeProfileIds.anyMatch(NATIVE_PROFILE_NAME::equalsIgnoreCase)) { - return true; - } - // recurse into parent (if available) - return Optional.ofNullable(mavenProject.getParent()).map(this::isNativeProfileEnabled).orElse(false); - } - @Override public void setLog(Log log) { super.setLog(log); 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 f303f390b8ec49..4bbf1b902d4b40 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java @@ -6,7 +6,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Properties; +import java.util.stream.Stream; import org.apache.maven.AbstractMavenLifecycleParticipant; import org.apache.maven.execution.MavenSession; @@ -34,6 +36,10 @@ public abstract class QuarkusBootstrapMojo extends AbstractMojo { static final String CLOSE_BOOTSTRAPPED_APP = "closeBootstrappedApp"; + static final String NATIVE_PACKAGE_TYPE = "native"; + static final String NATIVE_PROFILE_NAME = "native"; + static final String PACKAGE_TYPE_PROP = "quarkus.package.type"; + @Component protected QuarkusBootstrapProvider bootstrapProvider; @@ -293,4 +299,36 @@ protected CuratedApplication bootstrapApplication(LaunchMode mode) throws MojoEx protected Properties getBuildSystemProperties(boolean quarkusOnly) throws MojoExecutionException { return bootstrapProvider.bootstrapper(this).getBuildSystemProperties(this, quarkusOnly); } + + /** + * Essentially what this does is to enable the native package type even if a different package type is set + * in application properties. This is done to preserve what users expect to happen when + * they execute "mvn package -Dnative" even if quarkus.package.type has been set in application.properties + * + * @return true if the package type system property was set, otherwise - false + */ + protected boolean setPackageTypeSystemPropertyIfNativeProfileEnabled() { + if (!System.getProperties().containsKey(PACKAGE_TYPE_PROP) + && isNativeProfileEnabled(mavenProject())) { + Object packageTypeProp = mavenProject().getProperties().get(PACKAGE_TYPE_PROP); + String packageType = NATIVE_PACKAGE_TYPE; + if (packageTypeProp != null) { + packageType = packageTypeProp.toString(); + } + System.setProperty(PACKAGE_TYPE_PROP, packageType); + return true; + } + return false; + } + + private 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); + if (activeProfileIds.anyMatch(NATIVE_PROFILE_NAME::equalsIgnoreCase)) { + return true; + } + // recurse into parent (if available) + return Optional.ofNullable(mavenProject.getParent()).map(this::isNativeProfileEnabled).orElse(false); + } } diff --git a/devtools/maven/src/main/java/io/quarkus/maven/TrackConfigChangesMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/TrackConfigChangesMojo.java new file mode 100644 index 00000000000000..e721cf89e80c7f --- /dev/null +++ b/devtools/maven/src/main/java/io/quarkus/maven/TrackConfigChangesMojo.java @@ -0,0 +1,161 @@ +package io.quarkus.maven; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +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.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; + +import io.quarkus.bootstrap.app.CuratedApplication; +import io.quarkus.bootstrap.classloading.QuarkusClassLoader; +import io.quarkus.bootstrap.model.ApplicationModel; +import io.quarkus.deployment.configuration.tracker.ConfigTrackingWriter; +import io.quarkus.runtime.LaunchMode; + +/** + * Maven goal that is executed before the {@link BuildMojo}. + * The goal looks for a file that contains build time configuration options read during the previous build. + * If that file exists, the goal will check whether the configuration options used during the previous build + * have changed in the current configuration and will persist their current values to another file, so that + * both configuration files could be compared by tools caching build goal outcomes to check whether the previous + * outcome of the {@link BuildMojo} needs to be rebuilt. + */ +@Mojo(name = "track-config-changes", defaultPhase = LifecyclePhase.PROCESS_RESOURCES, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, threadSafe = true) +public class TrackConfigChangesMojo extends QuarkusBootstrapMojo { + + /** + * Skip the execution of this mojo + */ + @Parameter(defaultValue = "false", property = "quarkus.track-config-changes.skip") + boolean skip = false; + + @Parameter(property = "launchMode") + String mode; + + @Parameter(property = "quarkus.track-config-changes.outputDirectory", defaultValue = "${project.build.directory}") + File outputDirectory; + + @Parameter(property = "quarkus.track-config-changes.outputFile", required = false) + File outputFile; + + @Parameter(property = "quarkus.recorded-build-config.directory", defaultValue = "${basedir}/.quarkus") + File recordedBuildConfigDirectory; + + @Parameter(property = "quarkus.recorded-build-config.file", required = false) + File recordedBuildConfigFile; + + @Override + protected boolean beforeExecute() throws MojoExecutionException, MojoFailureException { + if (skip) { + getLog().info("Skipping config dump"); + return false; + } + return true; + } + + @Override + protected void doExecute() throws MojoExecutionException, MojoFailureException { + final String lifecyclePhase = mojoExecution.getLifecyclePhase(); + if (mode == null) { + if (lifecyclePhase == null) { + mode = "NORMAL"; + } else { + mode = lifecyclePhase.contains("test") ? "TEST" : "NORMAL"; + } + } + final LaunchMode launchMode = LaunchMode.valueOf(mode); + if (getLog().isDebugEnabled()) { + getLog().debug("Bootstrapping Quarkus application in mode " + launchMode); + } + + Path targetFile; + if (outputFile == null) { + targetFile = outputDirectory.toPath() + .resolve("quarkus-" + launchMode.getDefaultProfile() + "-config-check"); + } else if (outputFile.isAbsolute()) { + targetFile = outputFile.toPath(); + } else { + targetFile = outputDirectory.toPath().resolve(outputFile.toPath()); + } + + Path compareFile; + if (this.recordedBuildConfigFile == null) { + compareFile = recordedBuildConfigDirectory.toPath() + .resolve("quarkus-" + launchMode.getDefaultProfile() + "-config-dump"); + } else if (this.recordedBuildConfigFile.isAbsolute()) { + compareFile = this.recordedBuildConfigFile.toPath(); + } else { + compareFile = recordedBuildConfigDirectory.toPath().resolve(this.recordedBuildConfigFile.toPath()); + } + + if (!Files.exists(compareFile)) { + getLog().info(compareFile + " not found"); + return; + } + final Properties compareProps = new Properties(); + try (BufferedReader reader = Files.newBufferedReader(compareFile)) { + compareProps.load(reader); + } catch (IOException e) { + throw new RuntimeException("Failed to read " + compareFile, e); + } + + CuratedApplication curatedApplication = null; + QuarkusClassLoader deploymentClassLoader = null; + final ClassLoader originalCl = Thread.currentThread().getContextClassLoader(); + Properties actualProps; + final boolean clearPackageTypeSystemProperty = setPackageTypeSystemPropertyIfNativeProfileEnabled(); + try { + curatedApplication = bootstrapApplication(launchMode); + deploymentClassLoader = curatedApplication.createDeploymentClassLoader(); + Thread.currentThread().setContextClassLoader(deploymentClassLoader); + + final Class codeGenerator = deploymentClassLoader.loadClass("io.quarkus.deployment.CodeGenerator"); + final Method dumpConfig = codeGenerator.getMethod("readCurrentConfigValues", ApplicationModel.class, String.class, + Properties.class, QuarkusClassLoader.class, Properties.class); + actualProps = (Properties) dumpConfig.invoke(null, curatedApplication.getApplicationModel(), + launchMode.name(), getBuildSystemProperties(true), + deploymentClassLoader, compareProps); + } catch (Exception any) { + throw new MojoExecutionException("Failed to bootstrap Quarkus application", any); + } finally { + System.clearProperty(PACKAGE_TYPE_PROP); + Thread.currentThread().setContextClassLoader(originalCl); + if (deploymentClassLoader != null) { + deploymentClassLoader.close(); + } + } + + final List names = new ArrayList<>(actualProps.stringPropertyNames()); + Collections.sort(names); + + final Path outputDir = targetFile.getParent(); + if (outputDir != null && !Files.exists(outputDir)) { + try { + Files.createDirectories(outputDir); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + try (BufferedWriter writer = Files.newBufferedWriter(targetFile)) { + for (var name : names) { + ConfigTrackingWriter.write(writer, name, actualProps.getProperty(name)); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/docs/src/main/asciidoc/config-reference.adoc b/docs/src/main/asciidoc/config-reference.adoc index 41664ad7df60f3..b459f81b47915a 100644 --- a/docs/src/main/asciidoc/config-reference.adoc +++ b/docs/src/main/asciidoc/config-reference.adoc @@ -621,7 +621,7 @@ extensions. Therefore, the `quarkus.` prefix should **never** be used for applic === Build Time configuration -Some Quarkus configurations only take effect during build time, meaning is not possible to change them at runtime. These +Some Quarkus configurations only take effect during build time, meaning it is not possible to change them at runtime. These configurations are still available at runtime but as read-only and have no effect in Quarkus behaviour. A change to any of these configurations requires a rebuild of the application itself to reflect changes of such properties. @@ -635,6 +635,62 @@ application behaviour at runtime. If you are in the rare situation that you need to change the build time configuration after your application is built, then check out how xref:reaugmentation.adoc[re-augmentation] can be used to rebuild the augmentation output for a different build time configuration. +== Tracking effective build time configuration used at build time + +Given that often configuration sources will provide more options that will actually be used during the build, it might be useful in certain cases to know which configuration options have actually been used during a Quarkus build process. + +=== Dumping build time configuration options read during the build + +Setting `quarkus.config-tracking.enabled` to `true` will enable a configuration interceptor that will record every configuration option that was read during the build process along with their values. The resulting report will be stored in `${project.basedir}/.quarkus/quarkus-prod-config-dump` by default. The target file could be configured using the following options: + +* `quarkus.config-tracking.directory` - directory in which the configuration dump should be stored, the default is `${project.basedir}/.quarkus` +* `quarkus.config-tracking.file-prefix` - file name prefix, the default value is `quarkus` +* `quarkus.config-tracking.file-suffix` - file name suffix, the default value is `-config-dump` +* `quarkus.config-tracking.file` - path to a file in which the configuration dump should be stored. This option will make the rest of the above irrelevant unless the file path is a relative path, in which case it will related to the value of `quarkus.config-tracking.directory`. + +The `prod` part of the `quarkus-prod-config-dump` file name refers to the Quarkus build mode, indicating that the dump was taken for the production build. + +The reason `${project.basedir}/.quarkus` directory was chosen as the default location was to make it easy to track build time configuration changes between builds and use that as an indicator to build output caching tools (such as https://maven.apache.org/extensions/maven-build-cache-extension/[Apache Maven Build Cache] and https://gradle.com/gradle-enterprise-solutions/build-cache/[Gradle Enterprise Build Cache]) whether the application binary has to be re-built. + +==== Filtering configuration options + +Configuration tracker could be instructed to exclude some of the options from the report by configuring `quarkus.config-tracking.exclude` with a comma-separated list of configuration option names that should be filtered out. + +==== Path values + +Configuration options with *absolute* path values that begin with a user home directory are, by default, transformed with Unix home directory alias '~' replacing the user home directory part and using `/` as a path element separator. + +This transformation could be disabled by setting `quarkus.config-tracking.use-user-home-alias-in-paths` to `false`. + +==== Hashing recorded configuration values + +Configuration values can be hashed using `SHA-512` algorithm before they are written to a file. Configuration option names whose values should be hashed can be configured in `quarkus.config-tracking.hash-options` as a comma separated list. + +=== Tracking build time configuration changes between builds + +While `quarkus.config-tracking.enabled` enables effective build time configuration report generation, there is also a way to check whether the values stored in that report have changed before the next build of the project is launched. + +Maven projects could add the following goal to their `quarkus-maven-plugin` configuration: +[source,xml] +---- + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + track-prod-config-changes + process-resources + + track-config-changes + + + +---- + +The `track-config-canges` would be looking for `${project.basedir}/.quarkus/quarkus-prod-config-dump` by default (the file name and location could be configured) and if found, it will check whether the values stored in the config dump have now changed. It will log those options that have changed and save the current values of each of the options present in `${project.basedir}/.quarkus/quarkus-prod-config-dump` in `${project.basedir}/target/quarkus-prod-config.check` (the target file name and location can be configured). If the build time configuration has not changed since the last build both `${project.basedir}/.quarkus/quarkus-prod-config-dump` and `${project.basedir}/.quarkus/quarkus-prod-config-dump` should end up being identical. + [[additional-information]] == Additional Information diff --git a/integration-tests/maven/src/test/java/io/quarkus/maven/it/PackageIT.java b/integration-tests/maven/src/test/java/io/quarkus/maven/it/PackageIT.java index 86e576f03b12ad..b4349fbb87798c 100644 --- a/integration-tests/maven/src/test/java/io/quarkus/maven/it/PackageIT.java +++ b/integration-tests/maven/src/test/java/io/quarkus/maven/it/PackageIT.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.IOException; @@ -27,6 +28,7 @@ import io.quarkus.maven.it.verifier.MavenProcessInvocationResult; import io.quarkus.maven.it.verifier.RunningInvoker; +import io.quarkus.runtime.util.HashUtil; @DisableForNative public class PackageIT extends MojoTestBase { @@ -34,6 +36,42 @@ public class PackageIT extends MojoTestBase { private RunningInvoker running; private File testDir; + @Test + public void testConfigTracking() throws Exception { + testDir = initProject("projects/config-tracking"); + running = new RunningInvoker(testDir, false); + var configDump = new File(new File(testDir, ".quarkus"), "quarkus-prod-config-dump"); + var configCheck = new File(new File(testDir, "target"), "quarkus-prod-config-check"); + + // initial build that generates .quarkus/quarkus-prod-config-dump + var result = running.execute(List.of("clean package -DskipTests"), Map.of()); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + assertThat(configDump).exists(); + assertThat(configCheck).doesNotExist(); + + // rebuild and compare the files + result = running.execute(List.of("package -DskipTests"), Map.of()); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + assertThat(configDump).exists(); + assertThat(configCheck).exists(); + assertThat(configDump).hasSameTextualContentAs(configCheck); + + var props = new Properties(); + try (BufferedReader reader = Files.newBufferedReader(configDump.toPath())) { + props.load(reader); + } + assertThat(props.getProperty("quarkus.application.name")).isEqualTo(HashUtil.sha512("code-with-quarkus")); + + assertThat(props).doesNotContainKey("quarkus.platform.group-id"); + for (var name : props.stringPropertyNames()) { + assertThat(name).doesNotStartWith("quarkus.test."); + } + + result = running.execute(List.of("package -DskipTests -Dquarkus.package.type=uber-jar"), Map.of()); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + assertThat(running.log()).contains("Option quarkus.package.type has changed since the last build from jar to uber-jar"); + } + @Test public void testPluginClasspathConfig() throws Exception { testDir = initProject("projects/test-plugin-classpath-config"); diff --git a/integration-tests/maven/src/test/resources-filtered/projects/config-tracking/pom.xml b/integration-tests/maven/src/test/resources-filtered/projects/config-tracking/pom.xml new file mode 100644 index 00000000000000..434437b4d2610b --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/config-tracking/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + org.acme + code-with-quarkus + 1.0.0-SNAPSHOT + + ${compiler-plugin.version} + ${maven.compiler.release} + UTF-8 + UTF-8 + quarkus-bom + io.quarkus + ${project.version} + + + + + \${quarkus.platform.group-id} + \${quarkus.platform.artifact-id} + \${quarkus.platform.version} + pom + import + + + + + + io.quarkus + quarkus-resteasy-reactive + + + + + + \${quarkus.platform.group-id} + quarkus-maven-plugin + \${quarkus.platform.version} + true + + + track-prod-config-changes + process-resources + + track-config-changes + + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + \${compiler-plugin.version} + + + -parameters + + + + + + \ No newline at end of file diff --git a/integration-tests/maven/src/test/resources-filtered/projects/config-tracking/src/main/java/org/acme/GreetingResource.java b/integration-tests/maven/src/test/resources-filtered/projects/config-tracking/src/main/java/org/acme/GreetingResource.java new file mode 100644 index 00000000000000..6938062ec8ff7e --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/config-tracking/src/main/java/org/acme/GreetingResource.java @@ -0,0 +1,16 @@ +package org.acme; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/hello") +public class GreetingResource { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String hello() { + return "Hello from RESTEasy Reactive"; + } +} diff --git a/integration-tests/maven/src/test/resources-filtered/projects/config-tracking/src/main/resources/application.properties b/integration-tests/maven/src/test/resources-filtered/projects/config-tracking/src/main/resources/application.properties new file mode 100644 index 00000000000000..ff43978c7f6cae --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/config-tracking/src/main/resources/application.properties @@ -0,0 +1,3 @@ +quarkus.config-tracking.enabled=true +quarkus.config-tracking.hash-options=quarkus.application.* +quarkus.config-tracking.exclude=quarkus.test.*,quarkus.platform.group-id \ No newline at end of file