Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix parsing of unclean map values in Config. #4032

Merged
merged 1 commit into from
Aug 31, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,45 @@
package io.opentelemetry.instrumentation.api.config;

import java.time.Duration;
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

final class ConfigValueParsers {

static List<String> parseList(String value) {
String[] tokens = value.split(",", -1);
// Remove whitespace from each item.
for (int i = 0; i < tokens.length; i++) {
tokens[i] = tokens[i].trim();
}
return Collections.unmodifiableList(Arrays.asList(tokens));
return Collections.unmodifiableList(filterBlanksAndNulls(value.split(",")));
}

static Map<String, String> parseMap(String value) {
Map<String, String> result = new LinkedHashMap<>();
for (String token : value.split(",", -1)) {
token = token.trim();
String[] parts = token.split("=", -1);
if (parts.length != 2) {
throw new IllegalArgumentException(
"Invalid map config part, should be formatted key1=value1,key2=value2: " + value);
}
result.put(parts[0], parts[1]);
}
return Collections.unmodifiableMap(result);
return parseList(value).stream()
.map(keyValuePair -> filterBlanksAndNulls(keyValuePair.split("=", 2)))
.map(
splitKeyValuePairs -> {
if (splitKeyValuePairs.size() != 2) {
throw new IllegalArgumentException(
"Invalid map property, should be formatted key1=value1,key2=value2: " + value);
}
Comment on lines +26 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we allow empty values? e.g. key1=val1,key2=,key3=val3

Copy link
Contributor Author

@anuraaga anuraaga Aug 31, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do for getString (at least in SDK) though seem to be missing tests for it, and the map with empty values. I'd expect the two to be consistent. I think returning empty when it's empty is reasonable behavior, it's similar to how query params work

return new AbstractMap.SimpleImmutableEntry<>(
splitKeyValuePairs.get(0), splitKeyValuePairs.get(1));
})
// If duplicate keys, prioritize later ones similar to duplicate system properties on a
// Java command line.
.collect(
Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue, (first, next) -> next, LinkedHashMap::new));
}

private static List<String> filterBlanksAndNulls(String[] values) {
return Arrays.stream(values)
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}

static Duration parseDuration(String value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static java.util.Collections.singletonMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -150,17 +150,19 @@ void shouldGetMap() {
Config.newBuilder()
.addProperty("prop.map", "one=1, two=2")
.addProperty("prop.wrong", "one=1, but not two!")
.addProperty("prop.trailing", "one=1,")
.build();

assertEquals(map("one", "1", "two", "2"), config.getMap("prop.map"));
assertEquals(
map("one", "1", "two", "2"), config.getMap("prop.map", singletonMap("three", "3")));
assertTrue(config.getMap("prop.wrong").isEmpty());
assertEquals(
singletonMap("three", "3"), config.getMap("prop.wrong", singletonMap("three", "3")));
assertTrue(config.getMap("prop.missing").isEmpty());
assertEquals(
singletonMap("three", "3"), config.getMap("prop.missing", singletonMap("three", "3")));
assertThat(config.getMap("prop.map")).containsOnly(entry("one", "1"), entry("two", "2"));
assertThat(config.getMap("prop.map", singletonMap("three", "3")))
.containsOnly(entry("one", "1"), entry("two", "2"));
assertThat(config.getMap("prop.wrong")).isEmpty();
assertThat(config.getMap("prop.wrong", singletonMap("three", "3")))
.containsOnly(entry("three", "3"));
assertThat(config.getMap("prop.missing")).isEmpty();
assertThat(config.getMap("prop.missing", singletonMap("three", "3")))
.containsOnly(entry("three", "3"));
assertThat(config.getMap("prop.trailing")).containsOnly(entry("one", "1"));
}

@ParameterizedTest
Expand Down Expand Up @@ -216,11 +218,4 @@ public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
Arguments.of(asList("disabled-env", "test-env"), true, false));
}
}

public static Map<String, String> map(String k1, String v1, String k2, String v2) {
Map<String, String> map = new HashMap<>();
map.put(k1, v1);
map.put(k2, v2);
return map;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.javaagent.tooling.config;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.entry;

import io.opentelemetry.instrumentation.api.config.Config;
import io.opentelemetry.sdk.autoconfigure.ConfigProperties;
import io.opentelemetry.sdk.autoconfigure.ConfigurationException;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

// Copied from
// https://github.com/open-telemetry/opentelemetry-java/blob/main/sdk-extensions/autoconfigure/src/test/java/io/opentelemetry/sdk/autoconfigure/ConfigPropertiesTest.java
class ConfigPropertiesAdapterTest {

@Test
void allValid() {
Map<String, String> properties = new HashMap<>();
properties.put("string", "str");
properties.put("int", "10");
properties.put("long", "20");
properties.put("double", "5.4");
properties.put("list", "cat,dog,bear");
properties.put("map", "cat=meow,dog=bark,bear=growl");
properties.put("duration", "1s");

ConfigProperties config = createConfig(properties);
assertThat(config.getString("string")).isEqualTo("str");
assertThat(config.getInt("int")).isEqualTo(10);
assertThat(config.getLong("long")).isEqualTo(20);
assertThat(config.getDouble("double")).isEqualTo(5.4);
assertThat(config.getCommaSeparatedValues("list")).containsExactly("cat", "dog", "bear");
assertThat(config.getCommaSeparatedMap("map"))
.containsExactly(entry("cat", "meow"), entry("dog", "bark"), entry("bear", "growl"));
assertThat(config.getDuration("duration")).isEqualTo(Duration.ofSeconds(1));
}

@Test
void allMissing() {
ConfigProperties config = createConfig(Collections.emptyMap());
assertThat(config.getString("string")).isNull();
assertThat(config.getInt("int")).isNull();
assertThat(config.getLong("long")).isNull();
assertThat(config.getDouble("double")).isNull();
assertThat(config.getCommaSeparatedValues("list")).isEmpty();
assertThat(config.getCommaSeparatedMap("map")).isEmpty();
assertThat(config.getDuration("duration")).isNull();
}

@Test
void allEmpty() {
Map<String, String> properties = new HashMap<>();
properties.put("string", "");
properties.put("int", "");
properties.put("long", "");
properties.put("double", "");
properties.put("list", "");
properties.put("map", "");
properties.put("duration", "");

ConfigProperties config = createConfig(properties);
assertThat(config.getString("string")).isEmpty();
assertThat(config.getInt("int")).isNull();
assertThat(config.getLong("long")).isNull();
assertThat(config.getDouble("double")).isNull();
assertThat(config.getCommaSeparatedValues("list")).isEmpty();
assertThat(config.getCommaSeparatedMap("map")).isEmpty();
assertThat(config.getDuration("duration")).isNull();
}

@Test
@Disabled("Currently invalid values are not handled the same as the SDK")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mateuszrzeszutek I think we should make sure the behavior is the same as the SDK for these too

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that'd certainly make sense to do so. On the other hand, in the agent code I think we'd probably prefer to fall back on the default value instead of throwing an exception in the middle of an advice class. So, how about the following:

  • in the @Nullable X getX(String name) methods (these are used only in the SDK bridge, I think) we'll throw an exception if the value is invalid;
  • but in the X getX(String name, X default) (which are used pretty much everywhere in the agent) we'll return the default value in case of any errors

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think that's fine

void invalidInt() {
assertThatThrownBy(() -> createConfig(Collections.singletonMap("int", "bar")).getInt("int"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid value for property int=bar. Must be a integer.");
assertThatThrownBy(
() -> createConfig(Collections.singletonMap("int", "999999999999999")).getInt("int"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid value for property int=999999999999999. Must be a integer.");
}

@Test
@Disabled("Currently invalid values are not handled the same as the SDK")
void invalidLong() {
assertThatThrownBy(() -> createConfig(Collections.singletonMap("long", "bar")).getLong("long"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid value for property long=bar. Must be a long.");
assertThatThrownBy(
() ->
createConfig(Collections.singletonMap("long", "99223372036854775807"))
.getLong("long"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid value for property long=99223372036854775807. Must be a long.");
}

@Test
@Disabled("Currently invalid values are not handled the same as the SDK")
void invalidDouble() {
assertThatThrownBy(
() -> createConfig(Collections.singletonMap("double", "bar")).getDouble("double"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid value for property double=bar. Must be a double.");
assertThatThrownBy(
() -> createConfig(Collections.singletonMap("double", "1.0.1")).getDouble("double"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid value for property double=1.0.1. Must be a double.");
}

@Test
void uncleanList() {
assertThat(
createConfig(Collections.singletonMap("list", " a ,b,c , d,, ,"))
.getCommaSeparatedValues("list"))
.containsExactly("a", "b", "c", "d");
}

@Test
void uncleanMap() {
assertThat(
createConfig(Collections.singletonMap("map", " a=1 ,b=2,c = 3 , d= 4,, ,"))
.getCommaSeparatedMap("map"))
.containsExactly(entry("a", "1"), entry("b", "2"), entry("c", "3"), entry("d", "4"));
}

@Test
@Disabled("Currently invalid values are not handled the same as the SDK")
void invalidMap() {
assertThatThrownBy(
() ->
createConfig(Collections.singletonMap("map", "a=1,b=")).getCommaSeparatedMap("map"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid map property: map=a=1,b=");
assertThatThrownBy(
() ->
createConfig(Collections.singletonMap("map", "a=1,b")).getCommaSeparatedMap("map"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid map property: map=a=1,b");
assertThatThrownBy(
() ->
createConfig(Collections.singletonMap("map", "a=1,=b")).getCommaSeparatedMap("map"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid map property: map=a=1,=b");
}

@Test
@Disabled("Currently invalid values are not handled the same as the SDK")
void invalidDuration() {
assertThatThrownBy(
() ->
createConfig(Collections.singletonMap("duration", "1a1ms")).getDuration("duration"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid duration property duration=1a1ms. Expected number, found: 1a1");
assertThatThrownBy(
() -> createConfig(Collections.singletonMap("duration", "9mm")).getDuration("duration"))
.isInstanceOf(ConfigurationException.class)
.hasMessage("Invalid duration property duration=9mm. Invalid duration string, found: mm");
}

@Test
void durationUnitParsing() {
assertThat(createConfig(Collections.singletonMap("duration", "1")).getDuration("duration"))
.isEqualTo(Duration.ofMillis(1));
assertThat(createConfig(Collections.singletonMap("duration", "2ms")).getDuration("duration"))
.isEqualTo(Duration.ofMillis(2));
assertThat(createConfig(Collections.singletonMap("duration", "3s")).getDuration("duration"))
.isEqualTo(Duration.ofSeconds(3));
assertThat(createConfig(Collections.singletonMap("duration", "4m")).getDuration("duration"))
.isEqualTo(Duration.ofMinutes(4));
assertThat(createConfig(Collections.singletonMap("duration", "5h")).getDuration("duration"))
.isEqualTo(Duration.ofHours(5));
assertThat(createConfig(Collections.singletonMap("duration", "6d")).getDuration("duration"))
.isEqualTo(Duration.ofDays(6));
// Check Space handling
assertThat(createConfig(Collections.singletonMap("duration", "7 ms")).getDuration("duration"))
.isEqualTo(Duration.ofMillis(7));
assertThat(createConfig(Collections.singletonMap("duration", "8 ms")).getDuration("duration"))
.isEqualTo(Duration.ofMillis(8));
}

private static ConfigProperties createConfig(Map<String, String> properties) {
return new ConfigPropertiesAdapter(Config.newBuilder().readProperties(properties).build());
}
}