fix: provide better error messages for list options (#25918)

closes: #25235

Signed-off-by: Steve Hawkins <shawkins@redhat.com>
This commit is contained in:
Steven Hawkins 2024-01-15 12:33:49 -05:00 committed by GitHub
parent b1e3caebba
commit fe39d1b5eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 184 additions and 214 deletions

View file

@ -19,7 +19,7 @@ public class DatabaseOptions {
.category(OptionCategory.DATABASE) .category(OptionCategory.DATABASE)
.description("The database vendor.") .description("The database vendor.")
.defaultValue("dev-file") .defaultValue("dev-file")
.expectedValues(Database::getDatabaseAliases) .expectedValues(Database.getDatabaseAliases())
.buildTime(true) .buildTime(true)
.build(); .build();

View file

@ -10,18 +10,18 @@ import java.util.stream.Collectors;
public class FeatureOptions { public class FeatureOptions {
public static final Option<List> FEATURES = new OptionBuilder("features", List.class, Profile.Feature.class) public static final Option<List<String>> FEATURES = OptionBuilder.listOptionBuilder("features", String.class)
.category(OptionCategory.FEATURE) .category(OptionCategory.FEATURE)
.description("Enables a set of one or more features.") .description("Enables a set of one or more features.")
.defaultValue(Optional.empty()) .defaultValue(Optional.empty())
.expectedValues(() -> getFeatureValues(true)) .expectedValues(getFeatureValues(true))
.buildTime(true) .buildTime(true)
.build(); .build();
public static final Option<List> FEATURES_DISABLED = new OptionBuilder("features-disabled", List.class, Profile.Feature.class) public static final Option<List<String>> FEATURES_DISABLED = OptionBuilder.listOptionBuilder("features-disabled", String.class)
.category(OptionCategory.FEATURE) .category(OptionCategory.FEATURE)
.description("Disables a set of one or more features.") .description("Disables a set of one or more features.")
.expectedValues(() -> getFeatureValues(false)) .expectedValues(getFeatureValues(false))
.buildTime(true) .buildTime(true)
.build(); .build();

View file

@ -1,6 +1,9 @@
package org.keycloak.config; package org.keycloak.config;
import java.io.File; import java.io.File;
import java.util.Arrays;
import java.util.List;
import org.keycloak.common.crypto.FipsMode; import org.keycloak.common.crypto.FipsMode;
public class HttpOptions { public class HttpOptions {
@ -54,10 +57,10 @@ public class HttpOptions {
.description("The cipher suites to use. If none is given, a reasonable default is selected.") .description("The cipher suites to use. If none is given, a reasonable default is selected.")
.build(); .build();
public static final Option<String> HTTPS_PROTOCOLS = new OptionBuilder<>("https-protocols", String.class) public static final Option<List<String>> HTTPS_PROTOCOLS = OptionBuilder.listOptionBuilder("https-protocols", String.class)
.category(OptionCategory.HTTP) .category(OptionCategory.HTTP)
.description("The list of protocols to explicitly enable.") .description("The list of protocols to explicitly enable.")
.defaultValue("TLSv1.3,TLSv1.2") .defaultValue(Arrays.asList("TLSv1.3,TLSv1.2"))
.build(); .build();
public static final Option<File> HTTPS_CERTIFICATE_FILE = new OptionBuilder<>("https-certificate-file", File.class) public static final Option<File> HTTPS_CERTIFICATE_FILE = new OptionBuilder<>("https-certificate-file", File.class)

View file

@ -30,11 +30,11 @@ public class LoggingOptions {
.toList(); .toList();
} }
public static final Option<List> LOG = new OptionBuilder("log", List.class, Handler.class) public static final Option<List<Handler>> LOG = OptionBuilder.listOptionBuilder("log", Handler.class)
.category(OptionCategory.LOGGING) .category(OptionCategory.LOGGING)
.description("Enable one or more log handlers in a comma-separated list.") .description("Enable one or more log handlers in a comma-separated list.")
.expectedValues(() -> getAvailableHandlerNames()) .expectedValues(getAvailableHandlerNames())
.defaultValue(DEFAULT_LOG_HANDLER) .defaultValue(Arrays.asList(DEFAULT_LOG_HANDLER))
.build(); .build();
public enum Level { public enum Level {
@ -53,9 +53,9 @@ public class LoggingOptions {
} }
} }
public static final Option<String> LOG_LEVEL = new OptionBuilder<>("log-level", String.class) public static final Option<List<String>> LOG_LEVEL = OptionBuilder.listOptionBuilder("log-level", String.class)
.category(OptionCategory.LOGGING) .category(OptionCategory.LOGGING)
.defaultValue(DEFAULT_LOG_LEVEL.toString()) .defaultValue(Arrays.asList(DEFAULT_LOG_LEVEL.toString()))
.description("The log level of the root category or a comma-separated list of individual categories and their levels. For the root category, you don't need to specify a category.") .description("The log level of the root category or a comma-separated list of individual categories and their levels. For the root category, you don't need to specify a category.")
.build(); .build();

View file

@ -1,19 +0,0 @@
package org.keycloak.config;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
public class MultiOption<T> extends Option<T> {
private final Class auxiliaryType;
public MultiOption(Class type, Class auxiliaryType, String key, OptionCategory category, boolean hidden, boolean buildTime, String description, Optional defaultValue, Supplier<List<String>> expectedValues, DeprecatedMetadata deprecatedMetadata) {
super(type, key, category, hidden, buildTime, description, defaultValue, expectedValues, deprecatedMetadata);
this.auxiliaryType = auxiliaryType;
}
public Class<?> getAuxiliaryType() {
return auxiliaryType;
}
}

View file

@ -2,7 +2,7 @@ package org.keycloak.config;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.function.Supplier; import java.util.stream.Collectors;
public class Option<T> { public class Option<T> {
@ -13,10 +13,10 @@ public class Option<T> {
private final boolean buildTime; private final boolean buildTime;
private final String description; private final String description;
private final Optional<T> defaultValue; private final Optional<T> defaultValue;
private final Supplier<List<String>> expectedValues; private final List<String> expectedValues;
private final DeprecatedMetadata deprecatedMetadata; private final DeprecatedMetadata deprecatedMetadata;
public Option(Class<T> type, String key, OptionCategory category, boolean hidden, boolean buildTime, String description, Optional<T> defaultValue, Supplier<List<String>> expectedValues, DeprecatedMetadata deprecatedMetadata) { public Option(Class<T> type, String key, OptionCategory category, boolean hidden, boolean buildTime, String description, Optional<T> defaultValue, List<String> expectedValues, DeprecatedMetadata deprecatedMetadata) {
this.type = type; this.type = type;
this.key = key; this.key = key;
this.category = category; this.category = category;
@ -53,7 +53,7 @@ public class Option<T> {
} }
public List<String> getExpectedValues() { public List<String> getExpectedValues() {
return expectedValues.get(); return expectedValues;
} }
public Optional<DeprecatedMetadata> getDeprecatedMetadata() { public Optional<DeprecatedMetadata> getDeprecatedMetadata() {
@ -90,4 +90,14 @@ public class Option<T> {
return description; return description;
} }
public static String getDefaultValueString(Object value) {
if (value == null) {
return null;
}
if (value instanceof List) {
return ((List<?>) value).stream().map(String::valueOf).collect(Collectors.joining(","));
}
return String.valueOf(value);
}
} }

View file

@ -1,56 +1,54 @@
package org.keycloak.config; package org.keycloak.config;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class OptionBuilder<T> { public class OptionBuilder<T> {
private static final Supplier<List<String>> EMPTY_VALUES_SUPPLIER = List::of; private static final List<String> BOOLEAN_TYPE_VALUES = List.of(Boolean.TRUE.toString(), Boolean.FALSE.toString());
private static final Supplier<List<String>> BOOLEAN_TYPE_VALUES = new Supplier<List<String>>() {
List<String> values = List.of(Boolean.TRUE.toString(), Boolean.FALSE.toString());
@Override
public List<String> get() {
return values;
}
};
private final Class<T> type; private final Class<T> type;
private final Class<T> auxiliaryType;
private final String key; private final String key;
private OptionCategory category; private OptionCategory category;
private boolean hidden; private boolean hidden;
private boolean build; private boolean build;
private String description; private String description;
private Optional<T> defaultValue; private Optional<T> defaultValue;
private Supplier<List<String>> expectedValues; private List<String> expectedValues = List.of();
private DeprecatedMetadata deprecatedMetadata; private DeprecatedMetadata deprecatedMetadata;
public static <A> OptionBuilder<List<A>> listOptionBuilder(String key, Class<A> type) {
return new OptionBuilder(key, List.class, type);
}
public OptionBuilder(String key, Class<T> type) { public OptionBuilder(String key, Class<T> type) {
this(key, type, null); this(key, type, null);
} }
public OptionBuilder(String key, Class<T> type, Class<T> auxiliaryType) { private OptionBuilder(String key, Class<T> type, Class<?> auxiliaryType) {
this.type = type; this.type = type;
this.auxiliaryType = auxiliaryType; if (type.isArray() || ((Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type)) && type != java.util.List.class)) {
throw new IllegalArgumentException("Non-List multi-valued options are not yet supported");
}
this.key = key; this.key = key;
category = OptionCategory.GENERAL; category = OptionCategory.GENERAL;
hidden = false; hidden = false;
build = false; build = false;
description = null; description = null;
defaultValue = Boolean.class.equals(type) ? Optional.of((T) Boolean.FALSE) : Optional.empty(); Class<?> expected = type;
expectedValues = EMPTY_VALUES_SUPPLIER; if (auxiliaryType != null) {
if (Boolean.class.equals(type)) { expected = auxiliaryType;
}
defaultValue = Boolean.class.equals(expected) ? Optional.of((T) Boolean.FALSE) : Optional.empty();
if (Boolean.class.equals(expected)) {
expectedValues(BOOLEAN_TYPE_VALUES); expectedValues(BOOLEAN_TYPE_VALUES);
} }
if (Enum.class.isAssignableFrom(type)) { if (Enum.class.isAssignableFrom(expected)) {
expectedValues((Class<? extends Enum>) type); expectedValues((Class<? extends Enum>) expected);
}
if (auxiliaryType != null && Enum.class.isAssignableFrom(auxiliaryType)) {
expectedValues((Class<? extends Enum>) auxiliaryType);
} }
} }
@ -84,32 +82,18 @@ public class OptionBuilder<T> {
return this; return this;
} }
public OptionBuilder<T> expectedValues(Supplier<List<String>> expected) { public OptionBuilder<T> expectedValues(List<String> expected) {
this.expectedValues = expected; this.expectedValues = expected;
return this; return this;
} }
public OptionBuilder<T> expectedValues(Class<? extends Enum> expected) { public OptionBuilder<T> expectedValues(Class<? extends Enum> expected) {
this.expectedValues = new Supplier<>() { this.expectedValues = List.of(expected.getEnumConstants()).stream().map(Object::toString).collect(Collectors.toList());
List<String> values = List.of(expected.getEnumConstants()).stream().map(Object::toString).collect(Collectors.toList());
@Override
public List<String> get() {
return values;
}
};
return this; return this;
} }
public OptionBuilder<T> expectedValues(T ... expected) { public OptionBuilder<T> expectedValues(T ... expected) {
this.expectedValues = new Supplier<>() { this.expectedValues = List.of(expected).stream().map(v -> v.toString()).collect(Collectors.toList());
List<String> values = List.of(expected).stream().map(v -> v.toString()).collect(Collectors.toList());
@Override
public List<String> get() {
return values;
}
};
return this; return this;
} }
@ -135,11 +119,7 @@ public class OptionBuilder<T> {
public Option<T> build() { public Option<T> build() {
if (auxiliaryType != null) {
return new MultiOption<T>(type, auxiliaryType, key, category, hidden, build, description, defaultValue, expectedValues, deprecatedMetadata);
} else {
return new Option<T>(type, key, category, hidden, build, description, defaultValue, expectedValues, deprecatedMetadata); return new Option<T>(type, key, category, hidden, build, description, defaultValue, expectedValues, deprecatedMetadata);
} }
}
} }

View file

@ -10,7 +10,7 @@ public class SecurityOptions {
public static final Option<FipsMode> FIPS_MODE = new OptionBuilder<>("fips-mode", FipsMode.class) public static final Option<FipsMode> FIPS_MODE = new OptionBuilder<>("fips-mode", FipsMode.class)
.category(OptionCategory.SECURITY) .category(OptionCategory.SECURITY)
.expectedValues(SecurityOptions::getFipsModeValues) .expectedValues(getFipsModeValues())
.buildTime(true) .buildTime(true)
.description("Sets the FIPS mode. If '" + FipsMode.NON_STRICT + "' is set, FIPS is enabled but on non-approved mode. For full FIPS compliance, set '" + FipsMode.STRICT + "' to run on approved mode. " .description("Sets the FIPS mode. If '" + FipsMode.NON_STRICT + "' is set, FIPS is enabled but on non-approved mode. For full FIPS compliance, set '" + FipsMode.STRICT + "' to run on approved mode. "
+ "This option defaults to '" + FipsMode.DISABLED + "' when '" + Profile.Feature.FIPS.getKey() + "' feature is disabled, which is by default. " + "This option defaults to '" + FipsMode.DISABLED + "' when '" + Profile.Feature.FIPS.getKey() + "' feature is disabled, which is by default. "

View file

@ -2,9 +2,11 @@ package org.keycloak.config;
import org.keycloak.common.enums.HostnameVerificationPolicy; import org.keycloak.common.enums.HostnameVerificationPolicy;
import java.util.List;
public class TruststoreOptions { public class TruststoreOptions {
public static final Option<String> TRUSTSTORE_PATHS = new OptionBuilder<>("truststore-paths", String.class) public static final Option<List<String>> TRUSTSTORE_PATHS = OptionBuilder.listOptionBuilder("truststore-paths", String.class)
.category(OptionCategory.TRUSTSTORE) .category(OptionCategory.TRUSTSTORE)
.description("List of pkcs12 (p12 or pfx file extensions), PEM files, or directories containing those files that will be used as a system truststore.") .description("List of pkcs12 (p12 or pfx file extensions), PEM files, or directories containing those files that will be used as a system truststore.")
.build(); .build();

View file

@ -56,7 +56,7 @@ import java.util.stream.Collectors;
import org.eclipse.microprofile.config.spi.ConfigSource; import org.eclipse.microprofile.config.spi.ConfigSource;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.config.DeprecatedMetadata; import org.keycloak.config.DeprecatedMetadata;
import org.keycloak.config.MultiOption; import org.keycloak.config.Option;
import org.keycloak.config.OptionCategory; import org.keycloak.config.OptionCategory;
import org.keycloak.quarkus.runtime.cli.command.AbstractCommand; import org.keycloak.quarkus.runtime.cli.command.AbstractCommand;
import org.keycloak.quarkus.runtime.cli.command.Build; import org.keycloak.quarkus.runtime.cli.command.Build;
@ -399,7 +399,7 @@ public final class Picocli {
if (runtimeValue == null && isNotBlank(persistedValue)) { if (runtimeValue == null && isNotBlank(persistedValue)) {
PropertyMapper<?> mapper = PropertyMappers.getMapper(propertyName); PropertyMapper<?> mapper = PropertyMappers.getMapper(propertyName);
if (mapper != null && persistedValue.equals(mapper.getDefaultValue().map(Object::toString).orElse(null))) { if (mapper != null && persistedValue.equals(Option.getDefaultValueString(mapper.getDefaultValue().orElse(null)))) {
// same as default // same as default
continue; continue;
} }
@ -608,14 +608,11 @@ public final class Picocli {
.hidden(mapper.isHidden()); .hidden(mapper.isHidden());
if (mapper.getDefaultValue().isPresent()) { if (mapper.getDefaultValue().isPresent()) {
optBuilder.defaultValue(mapper.getDefaultValue().get().toString()); optBuilder.defaultValue(Option.getDefaultValueString(mapper.getDefaultValue().get()));
} }
if (mapper.getType() != null) { if (mapper.getType() != null) {
optBuilder.type(mapper.getType()); optBuilder.type(mapper.getType());
if (mapper.getOption() instanceof MultiOption) {
optBuilder.auxiliaryTypes(((MultiOption<?>) mapper.getOption()).getAuxiliaryType());
}
} else { } else {
optBuilder.type(String.class); optBuilder.type(String.class);
} }
@ -639,7 +636,7 @@ public final class Picocli {
} }
mapper.getDefaultValue() mapper.getDefaultValue()
.map(d -> d.toString().replaceAll("%", "%%")) // escape formats .map(d -> Option.getDefaultValueString(d).replaceAll("%", "%%")) // escape formats
.map(d -> " Default: " + d + ".") .map(d -> " Default: " + d + ".")
.ifPresent(transformedDesc::append); .ifPresent(transformedDesc::append);

View file

@ -19,11 +19,8 @@ package org.keycloak.quarkus.runtime.cli;
import static org.keycloak.quarkus.runtime.cli.Picocli.ARG_PREFIX; import static org.keycloak.quarkus.runtime.cli.Picocli.ARG_PREFIX;
import java.util.Collection;
import java.util.Stack; import java.util.Stack;
import org.keycloak.utils.StringUtil;
import picocli.CommandLine; import picocli.CommandLine;
import picocli.CommandLine.Model.ArgSpec; import picocli.CommandLine.Model.ArgSpec;
import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Model.CommandSpec;
@ -52,21 +49,27 @@ public final class PropertyMapperParameterConsumer implements CommandLine.IParam
CommandLine commandLine = commandSpec.commandLine(); CommandLine commandLine = commandSpec.commandLine();
if (args.isEmpty() || !isOptionValue(args.peek())) { if (args.isEmpty() || !isOptionValue(args.peek())) {
throw new ParameterException( throw new ParameterException(commandLine,
commandLine, "Missing required value for option '" + name + "' (" + argSpec.paramLabel() + ")." + getExpectedValuesMessage(argSpec.completionCandidates(), option.completionCandidates())); "Missing required value. " + getExpectedMessage(argSpec, option, name));
} }
// consumes the value, actual value validation will be performed later // consumes the value, actual value validation will be performed later
args.pop(); args.pop();
if (!args.isEmpty() && isOptionValue(args.peek())) { if (!args.isEmpty() && isOptionValue(args.peek())) {
throw new ParameterException( throw new ParameterException(commandLine, getExpectedMessage(argSpec, option, name));
commandLine, "Option '" + name + "' expects a single value (" + argSpec.paramLabel() + ")" + getExpectedValuesMessage(argSpec.completionCandidates(), option.completionCandidates()));
} }
} }
public static String getErrorMessage(String name, String value, Iterable<String> specCandidates, Iterable<String> optionCandidates) { private String getExpectedMessage(ArgSpec argSpec, OptionSpec option, String name) {
return "Invalid value for option '" + name + "': " + value + "." + getExpectedValuesMessage(specCandidates, optionCandidates); return String.format("Option '%s' (%s) expects %s.%s", name, argSpec.paramLabel(),
option.typeInfo().isMultiValue() ? "one or more comma separated values without whitespace": "a single value",
getExpectedValuesMessage(argSpec.completionCandidates(), option.completionCandidates()));
}
public static String getErrorMessage(String name, String value, Iterable<String> expected) {
return String.format("Invalid value for option '%s': %s.%s", name, value,
getExpectedValuesMessage(expected, expected));
} }
private boolean isOptionValue(String arg) { private boolean isOptionValue(String arg) {
@ -77,22 +80,4 @@ public final class PropertyMapperParameterConsumer implements CommandLine.IParam
return optionCandidates.iterator().hasNext() ? " Expected values are: " + String.join(", ", specCandidates) : ""; return optionCandidates.iterator().hasNext() ? " Expected values are: " + String.join(", ", specCandidates) : "";
} }
public static boolean isExpectedValue(Collection<String> expectedValues, String value) {
if (expectedValues.isEmpty()) {
// accept any
return true;
}
if (StringUtil.isBlank(value)) {
return false;
}
for (String v : value.split(",")) {
if (!expectedValues.contains(v)) {
return false;
}
}
return true;
}
} }

View file

@ -9,7 +9,6 @@ import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption; import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption;
@ -24,7 +23,8 @@ public final class FeaturePropertyMappers {
return new PropertyMapper[] { return new PropertyMapper[] {
fromOption(FeatureOptions.FEATURES) fromOption(FeatureOptions.FEATURES)
.paramLabel("feature") .paramLabel("feature")
.validator((mapper, value) -> validateEnabledFeatures(value.getValue())) .validator((mapper, value) -> mapper.validateExpectedValues(value,
(c, v) -> validateEnabledFeature(v)))
.build(), .build(),
fromOption(FeatureOptions.FEATURES_DISABLED) fromOption(FeatureOptions.FEATURES_DISABLED)
.paramLabel("feature") .paramLabel("feature")
@ -32,8 +32,7 @@ public final class FeaturePropertyMappers {
}; };
} }
public static void validateEnabledFeatures(String s) { public static void validateEnabledFeature(String feature) {
Stream.of(s.split(",")).forEach(feature -> {
if (!Profile.getFeatureVersions(feature).isEmpty()) { if (!Profile.getFeatureVersions(feature).isEmpty()) {
return; return;
} }
@ -57,11 +56,10 @@ public final class FeaturePropertyMappers {
feature, FeatureOptions.getFeatureValues(false))); feature, FeatureOptions.getFeatureValues(false)));
} }
int version = Integer.parseInt(matcher.group(2)); int version = Integer.parseInt(matcher.group(2));
if (featureVersions.stream().noneMatch(f -> f.getVersion() == version)) { if (!featureVersions.stream().anyMatch(f -> f.getVersion() == version)) {
throw new PropertyException( throw new PropertyException(
String.format("%s has an unrecognized feature version, it should be one of %s", feature, String.format("%s has an unrecognized feature version, it should be one of %s", feature,
featureVersions.stream().map(Feature::getVersion).map(String::valueOf).collect(Collectors.toList()))); featureVersions.stream().map(Feature::getVersion).map(String::valueOf).collect(Collectors.toList())));
} }
});
} }
} }

View file

@ -24,7 +24,6 @@ import static org.keycloak.quarkus.runtime.configuration.Configuration.toCliForm
import static org.keycloak.quarkus.runtime.configuration.Configuration.toEnvVarFormat; import static org.keycloak.quarkus.runtime.configuration.Configuration.toEnvVarFormat;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.BiFunction; import java.util.function.BiFunction;
@ -32,7 +31,6 @@ import java.util.function.BiFunction;
import io.smallrye.config.ConfigSourceInterceptorContext; import io.smallrye.config.ConfigSourceInterceptorContext;
import io.smallrye.config.ConfigValue; import io.smallrye.config.ConfigValue;
import org.jboss.logging.Logger;
import org.keycloak.config.DeprecatedMetadata; import org.keycloak.config.DeprecatedMetadata;
import org.keycloak.config.Option; import org.keycloak.config.Option;
import org.keycloak.config.OptionBuilder; import org.keycloak.config.OptionBuilder;
@ -69,8 +67,6 @@ public class PropertyMapper<T> {
private String cliFormat; private String cliFormat;
private BiConsumer<PropertyMapper<T>, ConfigValue> validator; private BiConsumer<PropertyMapper<T>, ConfigValue> validator;
private static final Logger logger = Logger.getLogger(PropertyMapper.class);
PropertyMapper(Option<T> option, String to, BiFunction<Optional<String>, ConfigSourceInterceptorContext, Optional<String>> mapper, PropertyMapper(Option<T> option, String to, BiFunction<Optional<String>, ConfigSourceInterceptorContext, Optional<String>> mapper,
String mapFrom, String paramLabel, boolean mask, BiConsumer<PropertyMapper<T>, ConfigValue> validator) { String mapFrom, String paramLabel, boolean mask, BiConsumer<PropertyMapper<T>, ConfigValue> validator) {
this.option = option; this.option = option;
@ -119,14 +115,14 @@ public class PropertyMapper<T> {
PropertyMapper<?> parentMapper = PropertyMappers.getMapper(parentKey); PropertyMapper<?> parentMapper = PropertyMappers.getMapper(parentKey);
if (parentMapper != null && parentMapper.getDefaultValue().isPresent()) { if (parentMapper != null && parentMapper.getDefaultValue().isPresent()) {
parentValue = ConfigValue.builder().withValue(parentMapper.getDefaultValue().get().toString()).build(); parentValue = ConfigValue.builder().withValue(Option.getDefaultValueString(parentMapper.getDefaultValue().get())).build();
} }
} }
return transformValue(name, ofNullable(parentValue == null ? null : parentValue.getValue()), context, null); return transformValue(name, ofNullable(parentValue == null ? null : parentValue.getValue()), context, null);
} }
ConfigValue defaultValue = transformValue(name, this.option.getDefaultValue().map(Objects::toString), context, null); ConfigValue defaultValue = transformValue(name, this.option.getDefaultValue().map(Option::getDefaultValueString), context, null);
if (defaultValue != null) { if (defaultValue != null) {
return defaultValue; return defaultValue;
@ -242,7 +238,7 @@ public class PropertyMapper<T> {
private String mapFrom = null; private String mapFrom = null;
private boolean isMasked = false; private boolean isMasked = false;
private String paramLabel; private String paramLabel;
private BiConsumer<PropertyMapper<T>, ConfigValue> validator = (mapper, value) -> mapper.validateExpectedValues(value); private BiConsumer<PropertyMapper<T>, ConfigValue> validator = (mapper, value) -> mapper.validateExpectedValues(value, (c, v) -> mapper.validateSingleValue(c, v));
public Builder(Option<T> option) { public Builder(Option<T> option) {
this.option = option; this.option = option;
@ -296,15 +292,39 @@ public class PropertyMapper<T> {
} }
} }
public void validateExpectedValues(ConfigValue value) { public void validateExpectedValues(ConfigValue configValue, BiConsumer<ConfigValue, String> singleValidator) {
if (PropertyMapperParameterConsumer.isExpectedValue(getExpectedValues(), value.getValue())) { String value = configValue.getValue();
return;
boolean multiValued = getOption().getType() == java.util.List.class;
String[] values = multiValued ? value.split(",") : new String[] { value };
for (String v : values) {
boolean cli = isCliOption(configValue);
if (multiValued && !v.trim().equals(v)) {
throw new PropertyException("Invalid value for multivalued option '" + (cli ? this.getCliFormat() : getFrom())
+ "': list value '" + v + "' should not have leading nor trailing whitespace"
+ getConfigSourceMessage(configValue, cli));
} }
boolean cli = Optional.ofNullable(value.getConfigSourceName()).filter(name -> name.contains(ConfigArgsConfigSource.NAME)).isPresent(); singleValidator.accept(configValue, v);
}
}
private boolean isCliOption(ConfigValue configValue) {
return Optional.ofNullable(configValue.getConfigSourceName()).filter(name -> name.contains(ConfigArgsConfigSource.NAME)).isPresent();
}
void validateSingleValue(ConfigValue configValue, String v) {
List<String> expectedValues = getExpectedValues();
if (!expectedValues.isEmpty() && !expectedValues.contains(v)) {
boolean cli = isCliOption(configValue);
throw new PropertyException( throw new PropertyException(
PropertyMapperParameterConsumer.getErrorMessage(cli ? this.getCliFormat() : getFrom(), PropertyMapperParameterConsumer.getErrorMessage(cli ? this.getCliFormat() : getFrom(), v,
value.getValue(), getExpectedValues(), getExpectedValues()) expectedValues) + getConfigSourceMessage(configValue, cli));
+ (cli ? "" : ". From ConfigSource " + value.getConfigSourceName())); }
}
String getConfigSourceMessage(ConfigValue configValue, boolean cli) {
return cli ? "" : ". From ConfigSource " + configValue.getConfigSourceName();
} }
} }

View file

@ -28,28 +28,28 @@ public class FeaturePropertyMappersTest {
@Test @Test
public void testInvalidFeatureFormat() { public void testInvalidFeatureFormat() {
assertThrows(PropertyException.class, () -> FeaturePropertyMappers.validateEnabledFeatures("invalid:")); assertThrows(PropertyException.class, () -> FeaturePropertyMappers.validateEnabledFeature("invalid:"));
} }
@Test @Test
public void testInvalidFeature() { public void testInvalidFeature() {
assertThrows(PropertyException.class, () -> FeaturePropertyMappers.validateEnabledFeatures("invalid")); assertThrows(PropertyException.class, () -> FeaturePropertyMappers.validateEnabledFeature("invalid"));
} }
@Test @Test
public void testInvalidVersionedFeature() { public void testInvalidVersionedFeature() {
assertThrows(PropertyException.class, () -> FeaturePropertyMappers.validateEnabledFeatures("invalid:v1")); assertThrows(PropertyException.class, () -> FeaturePropertyMappers.validateEnabledFeature("invalid:v1"));
} }
@Test @Test
public void testInvalidFeatureVersion() { public void testInvalidFeatureVersion() {
assertThrows(PropertyException.class, () -> FeaturePropertyMappers.validateEnabledFeatures(Feature.DOCKER.getUnversionedKey() + ":v0")); assertThrows(PropertyException.class, () -> FeaturePropertyMappers.validateEnabledFeature(Feature.DOCKER.getUnversionedKey() + ":v0"));
} }
@Test @Test
public void testValidFeatures() { public void testValidFeatures() {
FeaturePropertyMappers.validateEnabledFeatures( FeaturePropertyMappers.validateEnabledFeature("preview");
Feature.DOCKER.getUnversionedKey() + "," + "preview" + "," + Feature.ACCOUNT2.getVersionedKey()); FeaturePropertyMappers.validateEnabledFeature(Feature.ACCOUNT2.getVersionedKey());
} }
} }

View file

@ -36,14 +36,28 @@ public class OptionValidationTest {
@Launch({"build", "--db"}) @Launch({"build", "--db"})
public void failMissingOptionValue(LaunchResult result) { public void failMissingOptionValue(LaunchResult result) {
CLIResult cliResult = (CLIResult) result; CLIResult cliResult = (CLIResult) result;
assertThat(cliResult.getErrorOutput(), containsString("Missing required value for option '--db' (vendor). Expected values are: dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres")); assertThat(cliResult.getErrorOutput(), containsString("Missing required value. Option '--db' (vendor) expects a single value. Expected values are: dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres"));
} }
@Test @Test
@Launch({"build", "--db", "foo", "bar"}) @Launch({"build", "--db", "foo", "bar"})
public void failMultipleOptionValue(LaunchResult result) { public void failMultipleOptionValue(LaunchResult result) {
CLIResult cliResult = (CLIResult) result; CLIResult cliResult = (CLIResult) result;
assertThat(cliResult.getErrorOutput(), containsString("Option '--db' expects a single value (vendor) Expected values are: dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres")); assertThat(cliResult.getErrorOutput(), containsString("Option '--db' (vendor) expects a single value. Expected values are: dev-file, dev-mem, mariadb, mssql, mysql, oracle, postgres"));
}
@Test
@Launch({"build", "--features", "account2", "account3"})
public void failMultipleMultiOptionValue(LaunchResult result) {
CLIResult cliResult = (CLIResult) result;
assertThat(cliResult.getErrorOutput(), containsString("Option '--features' (feature) expects one or more comma separated values without whitespace. Expected values are: "));
}
@Test
@Launch({"build", "--features", "xyz,account2"})
public void failInvalidMultiOptionValue(LaunchResult result) {
CLIResult cliResult = (CLIResult) result;
assertThat(cliResult.getErrorOutput(), containsString("xyz is an unrecognized feature, it should be one of"));
} }
@Test @Test

View file

@ -145,7 +145,7 @@ public class LoggingDistTest {
void failUnknownHandlersInConfFile(KeycloakDistribution dist) { void failUnknownHandlersInConfFile(KeycloakDistribution dist) {
dist.copyOrReplaceFileFromClasspath("/logging/keycloak.conf", Paths.get("conf", "keycloak.conf")); dist.copyOrReplaceFileFromClasspath("/logging/keycloak.conf", Paths.get("conf", "keycloak.conf"));
CLIResult cliResult = dist.run("start-dev"); CLIResult cliResult = dist.run("start-dev");
cliResult.assertError("Invalid value for option 'kc.log': foo,console. Expected values are: console, file, gelf."); cliResult.assertError("Invalid value for option 'kc.log': foo. Expected values are: console, file, gelf.");
} }
@Test @Test
@ -159,7 +159,7 @@ public class LoggingDistTest {
@Launch({ "start-dev","--log=foo,bar" }) @Launch({ "start-dev","--log=foo,bar" })
void failUnknownHandlersInCliCommand(LaunchResult result) { void failUnknownHandlersInCliCommand(LaunchResult result) {
CLIResult cliResult = (CLIResult) result; CLIResult cliResult = (CLIResult) result;
cliResult.assertError("Invalid value for option '--log': foo,bar"); cliResult.assertError("Invalid value for option '--log': foo");
} }
@Test @Test

View file

@ -28,7 +28,6 @@ import java.util.concurrent.TimeoutException;
import org.eclipse.microprofile.config.spi.ConfigProviderResolver; import org.eclipse.microprofile.config.spi.ConfigProviderResolver;
import org.keycloak.common.Version; import org.keycloak.common.Version;
import org.keycloak.common.crypto.FipsMode; import org.keycloak.common.crypto.FipsMode;
import org.keycloak.config.DatabaseOptions;
import org.keycloak.config.HttpOptions; import org.keycloak.config.HttpOptions;
import org.keycloak.config.LoggingOptions; import org.keycloak.config.LoggingOptions;
import org.keycloak.config.Option; import org.keycloak.config.Option;
@ -117,7 +116,7 @@ public class Keycloak {
addOptionIfNotSet(args, HttpOptions.HTTP_PORT); addOptionIfNotSet(args, HttpOptions.HTTP_PORT);
addOptionIfNotSet(args, HttpOptions.HTTPS_PORT); addOptionIfNotSet(args, HttpOptions.HTTPS_PORT);
boolean isFipsEnabled = ofNullable(getOptionValue(args, SecurityOptions.FIPS_MODE)).orElse(FipsMode.DISABLED).isFipsEnabled(); boolean isFipsEnabled = ofNullable(getOptionValue(args, SecurityOptions.FIPS_MODE)).map(FipsMode::valueOf).orElse(FipsMode.DISABLED).isFipsEnabled();
if (isFipsEnabled) { if (isFipsEnabled) {
String logLevel = getOptionValue(args, LoggingOptions.LOG_LEVEL); String logLevel = getOptionValue(args, LoggingOptions.LOG_LEVEL);
@ -135,41 +134,22 @@ public class Keycloak {
} }
private <T> void addOptionIfNotSet(List<String> args, Option<T> option, T defaultValue) { private <T> void addOptionIfNotSet(List<String> args, Option<T> option, T defaultValue) {
T value = getOptionValue(args, option); String value = getOptionValue(args, option);
if (value == null) { if (value == null) {
defaultValue = ofNullable(defaultValue).orElseGet(option.getDefaultValue()::get); defaultValue = ofNullable(defaultValue).orElseGet(option.getDefaultValue()::get);
args.add(Configuration.toCliFormat(option.getKey()) + "=" + defaultValue); args.add(Configuration.toCliFormat(option.getKey()) + "=" + Option.getDefaultValueString(defaultValue));
} }
} }
private <T> T getOptionValue(List<String> args, Option<T> option) { private String getOptionValue(List<String> args, Option<?> option) {
for (String arg : args) { for (String arg : args) {
if (arg.contains(option.getKey())) { if (arg.contains(option.getKey())) {
if (arg.endsWith(option.getKey())) { if (arg.endsWith(option.getKey())) {
throw new IllegalArgumentException("Option '" + arg + "' value must be set using '=' as a separator"); throw new IllegalArgumentException("Option '" + arg + "' value must be set using '=' as a separator");
} }
String value = arg.substring(Picocli.ARG_PREFIX.length() + option.getKey().length() + 1); return arg.substring(Picocli.ARG_PREFIX.length() + option.getKey().length() + 1);
Class<T> type = option.getType();
if (type.equals(String.class)) {
return (T) value;
}
if (type.isEnum()) {
return (T) Enum.valueOf((Class<Enum>) type, value);
}
if (Integer.class.isAssignableFrom(type)) {
return (T) Integer.valueOf(value);
}
if (Boolean.class.isAssignableFrom(type)) {
return (T) Boolean.valueOf(value);
}
throw new RuntimeException("Unsupported option type '" + type + "'");
} }
} }

View file

@ -22,7 +22,10 @@ import io.quarkus.runtime.configuration.QuarkusConfigFactory;
import io.quarkus.test.junit.QuarkusMainTestExtension; import io.quarkus.test.junit.QuarkusMainTestExtension;
import io.quarkus.test.junit.main.Launch; import io.quarkus.test.junit.main.Launch;
import io.quarkus.test.junit.main.LaunchResult; import io.quarkus.test.junit.main.LaunchResult;
import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;
import org.junit.jupiter.api.extension.ParameterContext; import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException; import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ReflectiveInvocationContext; import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
@ -35,20 +38,20 @@ import org.keycloak.quarkus.runtime.cli.command.StartDev;
import org.keycloak.quarkus.runtime.configuration.KeycloakPropertiesConfigSource; import org.keycloak.quarkus.runtime.configuration.KeycloakPropertiesConfigSource;
import org.keycloak.quarkus.runtime.configuration.test.TestConfigArgsConfigSource; import org.keycloak.quarkus.runtime.configuration.test.TestConfigArgsConfigSource;
import org.keycloak.quarkus.runtime.integration.QuarkusPlatform; import org.keycloak.quarkus.runtime.integration.QuarkusPlatform;
import org.testcontainers.containers.GenericContainer;
import java.io.IOException; import java.io.IOException;
import java.lang.annotation.Annotation; import java.lang.annotation.Annotation;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Properties;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import static java.lang.System.setProperty;
import static org.keycloak.it.junit5.extension.DistributionTest.ReInstall.BEFORE_ALL; import static org.keycloak.it.junit5.extension.DistributionTest.ReInstall.BEFORE_ALL;
import static org.keycloak.it.junit5.extension.DistributionType.RAW; import static org.keycloak.it.junit5.extension.DistributionType.RAW;
import static org.keycloak.quarkus.runtime.Environment.forceTestLaunchMode; import static org.keycloak.quarkus.runtime.Environment.forceTestLaunchMode;
@ -57,9 +60,9 @@ import static org.keycloak.quarkus.runtime.cli.command.Main.CONFIG_FILE_SHORT_NA
public class CLITestExtension extends QuarkusMainTestExtension { public class CLITestExtension extends QuarkusMainTestExtension {
private static final String SYS_PROPS = "sys-props";
private static final String KEY_VALUE_SEPARATOR = "[= ]"; private static final String KEY_VALUE_SEPARATOR = "[= ]";
private KeycloakDistribution dist; private KeycloakDistribution dist;
private final Set<String> testSysProps = new HashSet<>();
private DatabaseContainer databaseContainer; private DatabaseContainer databaseContainer;
private InfinispanContainer infinispanContainer; private InfinispanContainer infinispanContainer;
private CLIResult result; private CLIResult result;
@ -68,6 +71,7 @@ public class CLITestExtension extends QuarkusMainTestExtension {
public void beforeEach(ExtensionContext context) throws Exception { public void beforeEach(ExtensionContext context) throws Exception {
DistributionTest distConfig = getDistributionConfig(context); DistributionTest distConfig = getDistributionConfig(context);
Launch launch = context.getRequiredTestMethod().getAnnotation(Launch.class); Launch launch = context.getRequiredTestMethod().getAnnotation(Launch.class);
getStore(context).put(SYS_PROPS, new HashMap<>(System.getProperties()));
if (launch != null) { if (launch != null) {
for (String arg : launch.value()) { for (String arg : launch.value()) {
@ -117,6 +121,10 @@ public class CLITestExtension extends QuarkusMainTestExtension {
} }
} }
private Store getStore(ExtensionContext context) {
return context.getStore(Namespace.create(context.getRequiredTestClass(), context.getRequiredTestMethod()));
}
private static LegacyStore getLegacyStoreConfig(ExtensionContext context) { private static LegacyStore getLegacyStoreConfig(ExtensionContext context) {
return context.getTestClass().get().getDeclaredAnnotation(LegacyStore.class); return context.getTestClass().get().getDeclaredAnnotation(LegacyStore.class);
} }
@ -180,18 +188,15 @@ public class CLITestExtension extends QuarkusMainTestExtension {
} }
super.afterEach(context); super.afterEach(context);
reset(distConfig); reset(distConfig, context);
} }
private void reset(DistributionTest distConfig) { private void reset(DistributionTest distConfig, ExtensionContext context) {
QuarkusConfigFactory.setConfig(null); QuarkusConfigFactory.setConfig(null);
//remove the config file property if set, and also the profile, to not have side effects in other tests. HashMap props = getStore(context).remove(SYS_PROPS, HashMap.class);
System.getProperties().remove(Environment.PROFILE); System.getProperties().clear();
System.getProperties().remove("quarkus.profile"); System.getProperties().putAll(props);
TestConfigArgsConfigSource.setCliArgs(new String[0]); TestConfigArgsConfigSource.setCliArgs(new String[0]);
for (String property : testSysProps) {
System.getProperties().remove(property);
}
if (databaseContainer != null && databaseContainer.isRunning()) { if (databaseContainer != null && databaseContainer.isRunning()) {
databaseContainer.stop(); databaseContainer.stop();
databaseContainer = null; databaseContainer = null;
@ -367,11 +372,6 @@ public class CLITestExtension extends QuarkusMainTestExtension {
} }
} }
private void setProperty(String name, String value) {
System.setProperty(name, value);
testSysProps.add(name);
}
private List<String> getCliArgs(ExtensionContext context) { private List<String> getCliArgs(ExtensionContext context) {
Launch annotation = context.getRequiredTestMethod().getAnnotation(Launch.class); Launch annotation = context.getRequiredTestMethod().getAnnotation(Launch.class);