core: handle long dns txt records properly, parse service config, and add tests
diff --git a/core/src/main/java/io/grpc/internal/DnsNameResolver.java b/core/src/main/java/io/grpc/internal/DnsNameResolver.java
index 4d028c3..aa32abd 100644
--- a/core/src/main/java/io/grpc/internal/DnsNameResolver.java
+++ b/core/src/main/java/io/grpc/internal/DnsNameResolver.java
@@ -21,6 +21,11 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Verify;
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonSyntaxException;
import io.grpc.Attributes;
import io.grpc.EquivalentAddressGroup;
import io.grpc.NameResolver;
@@ -34,7 +39,12 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
+import java.util.Map.Entry;
+import java.util.Random;
+import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
@@ -62,14 +72,21 @@
private static final Logger logger = Logger.getLogger(DnsNameResolver.class.getName());
private static final boolean JNDI_AVAILABLE = jndiAvailable();
+ private static final String LOCAL_HOST_NAME = getHostName();
// From https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md
private static final String SERVICE_CONFIG_NAME_PREFIX = "_grpc_config.";
// From https://github.com/grpc/proposal/blob/master/A5-grpclb-in-dns.md
private static final String GRPCLB_NAME_PREFIX = "_grpclb._tcp.";
+ // From https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md
+ private static final String SERVICE_CONFIG_PREFIX = "_grpc_config=";
+ private static final Set<String> SERVICE_CONFIG_CHOICE_KEYS =
+ Collections.unmodifiableSet(
+ new HashSet<String>(
+ Arrays.asList("clientLanguage", "percentage", "clientHostname", "serviceConfig")));
private static final String JNDI_PROPERTY =
- System.getProperty("io.grpc.internal.DnsNameResolverProvider.enable_jndi", "false");
+ System.getProperty("io.grpc.internal.DnsNameResolverProvider.enable_jndi", "true");
@VisibleForTesting
static boolean enableJndi = Boolean.parseBoolean(JNDI_PROPERTY);
@@ -95,6 +112,8 @@
@GuardedBy("this")
private Listener listener;
+ private final Random random = new Random();
+
DnsNameResolver(@Nullable String nsAuthority, String name, Attributes params,
Resource<ScheduledExecutorService> timerServiceResource,
Resource<ExecutorService> executorResource,
@@ -194,13 +213,29 @@
Attributes.Builder attrs = Attributes.newBuilder();
if (!resolvedInetAddrs.txtRecords.isEmpty()) {
- // TODO(carl-mastrangelo): re enable this
- /*
- attrs.set(
- GrpcAttributes.NAME_RESOLVER_ATTR_SERVICE_CONFIG,
- Collections.unmodifiableList(new ArrayList<String>(resolvedInetAddrs.txtRecords)));
- */
+ JsonObject serviceConfig = null;
+ try {
+ JsonArray allServiceConfigChoices = parseTxtResults(resolvedInetAddrs.txtRecords);
+ for (JsonElement serviceConfigChoice : allServiceConfigChoices) {
+ try {
+ serviceConfig = maybeChooseServiceConfig(
+ serviceConfigChoice.getAsJsonObject(), random, LOCAL_HOST_NAME);
+ if (serviceConfig != null) {
+ break;
+ }
+ } catch (RuntimeException e) {
+ logger.log(Level.WARNING, "Bad service config choice " + serviceConfigChoice, e);
+ }
+ }
+ if (serviceConfig != null) {
+ attrs.set(GrpcAttributes.NAME_RESOLVER_ATTR_SERVICE_CONFIG, serviceConfig);
+ }
+ } catch (RuntimeException e) {
+ logger.log(Level.WARNING, "Can't parse service Configs", e);
+ }
+ } else {
+ logger.log(Level.FINE, "No TXT records found for {0}", new Object[]{host});
}
savedListener.onAddresses(servers, attrs.build());
} finally {
@@ -387,7 +422,6 @@
try {
serviceConfigTxtRecords = getAllRecords("TXT", "dns:///" + serviceConfigHostname);
} catch (NamingException e) {
-
if (logger.isLoggable(Level.FINE)) {
logger.log(Level.FINE, "Unable to look up " + serviceConfigHostname, e);
}
@@ -451,7 +485,7 @@
NamingEnumeration<?> rrValues = rrEntry.getAll();
try {
while (rrValues.hasMore()) {
- records.add(String.valueOf(rrValues.next()));
+ records.add(unquote(String.valueOf(rrValues.next())));
}
} finally {
rrValues.close();
@@ -463,4 +497,124 @@
return records;
}
}
+
+ @VisibleForTesting
+ static JsonArray parseTxtResults(List<String> txtRecords) {
+ JsonArray serviceConfigs = new JsonArray();
+ Gson gson = new Gson();
+
+ for (String txtRecord : txtRecords) {
+ if (txtRecord.startsWith(SERVICE_CONFIG_PREFIX)) {
+ JsonArray choices;
+ try {
+ choices =
+ gson.fromJson(txtRecord.substring(SERVICE_CONFIG_PREFIX.length()), JsonArray.class);
+ } catch (JsonSyntaxException e) {
+ logger.log(Level.WARNING, "Bad service config: " + txtRecord, e);
+ continue;
+ }
+ serviceConfigs.addAll(choices);
+ } else {
+ logger.log(Level.FINE, "Ignoring non service config {0}", new Object[]{txtRecord});
+ }
+ }
+
+ return serviceConfigs;
+ }
+
+ /**
+ * Determines if a given Service Config choice applies, and if so, returns it.
+ *
+ * @see <a href="https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md">
+ * Service Config in DNS</a>
+ * @param choice The service config choice.
+ * @return The service config object or {@code null} if this choice does not apply.
+ */
+ @VisibleForTesting
+ @Nullable
+ @SuppressWarnings("BetaApi") // Verify isn't all that beta
+ static JsonObject maybeChooseServiceConfig(JsonObject choice, Random random, String hostname) {
+ for (Entry<String, ?> entry : choice.entrySet()) {
+ Verify.verify(SERVICE_CONFIG_CHOICE_KEYS.contains(entry.getKey()), "Bad key: %s", entry);
+ }
+ if (choice.has("clientLanguage")) {
+ JsonArray clientLanguages = choice.get("clientLanguage").getAsJsonArray();
+ if (clientLanguages.size() != 0) {
+ boolean javaPresent = false;
+ for (JsonElement clientLanguage : clientLanguages) {
+ String lang = clientLanguage.getAsString().toLowerCase(Locale.ROOT);
+ if ("java".equals(lang)) {
+ javaPresent = true;
+ break;
+ }
+ }
+ if (!javaPresent) {
+ return null;
+ }
+ }
+ }
+ if (choice.has("percentage")) {
+ int pct = choice.get("percentage").getAsInt();
+ Verify.verify(pct >= 0 && pct <= 100, "Bad percentage", choice.get("percentage"));
+ if (pct == 0) {
+ return null;
+ } else if (pct != 100 && pct < random.nextInt(100)) {
+ return null;
+ }
+ }
+ if (choice.has("clientHostname")) {
+ JsonArray clientHostnames = choice.get("clientHostname").getAsJsonArray();
+ if (clientHostnames.size() != 0) {
+ boolean hostnamePresent = false;
+ for (JsonElement clientHostname : clientHostnames) {
+ if (clientHostname.getAsString().equals(hostname)) {
+ hostnamePresent = true;
+ break;
+ }
+ }
+ if (!hostnamePresent) {
+ return null;
+ }
+ }
+ }
+ return choice.getAsJsonObject("serviceConfig");
+ }
+
+ /**
+ * Undo the quoting done in {@link com.sun.jndi.dns.ResourceRecord#decodeTxt}.
+ */
+ @VisibleForTesting
+ static String unquote(String txtRecord) {
+ StringBuilder sb = new StringBuilder(txtRecord.length());
+ boolean inquote = false;
+ for (int i = 0; i < txtRecord.length(); i++) {
+ char c = txtRecord.charAt(i);
+ if (!inquote) {
+ if (c == ' ') {
+ continue;
+ } else if (c == '"') {
+ inquote = true;
+ continue;
+ }
+ } else {
+ if (c == '"') {
+ inquote = false;
+ continue;
+ } else if (c == '\\') {
+ c = txtRecord.charAt(++i);
+ assert c == '"' || c == '\\';
+ }
+ }
+ sb.append(c);
+ }
+ return sb.toString();
+ }
+
+ private static final String getHostName() {
+ try {
+ return InetAddress.getLocalHost().getHostName();
+ } catch (UnknownHostException e) {
+ throw new RuntimeException(e);
+ }
+ }
}
diff --git a/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java b/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java
index 7183217..f7717b3 100644
--- a/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java
+++ b/core/src/test/java/io/grpc/internal/DnsNameResolverTest.java
@@ -19,6 +19,7 @@
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@@ -32,6 +33,9 @@
import com.google.common.base.MoreObjects;
import com.google.common.collect.Iterables;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
import io.grpc.Attributes;
import io.grpc.EquivalentAddressGroup;
import io.grpc.NameResolver;
@@ -50,6 +54,7 @@
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
+import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@@ -58,6 +63,7 @@
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
+import org.junit.rules.ExpectedException;
import org.junit.rules.Timeout;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@@ -70,7 +76,8 @@
@RunWith(JUnit4.class)
public class DnsNameResolverTest {
- @Rule public final Timeout globalTimeout = Timeout.seconds(10);
+ @Rule public final Timeout globalTimeout = Timeout.seconds(100000);
+ @Rule public final ExpectedException thrown = ExpectedException.none();
private static final int DEFAULT_PORT = 887;
private static final Attributes NAME_RESOLVER_PARAMS =
@@ -385,6 +392,202 @@
assertTrue(((InetSocketAddress) socketAddress).isUnresolved());
}
+ @Test
+ public void unquoteRemovesJndiFormatting() {
+ assertEquals("blah", DnsNameResolver.unquote("blah"));
+ assertEquals("", DnsNameResolver.unquote("\"\""));
+ assertEquals("blahblah", DnsNameResolver.unquote("blah blah"));
+ assertEquals("blahfoo blah", DnsNameResolver.unquote("blah \"foo blah\""));
+ assertEquals("blah blah", DnsNameResolver.unquote("\"blah blah\""));
+ assertEquals("blah\"blah", DnsNameResolver.unquote("\"blah\\\"blah\""));
+ assertEquals("blah\\blah", DnsNameResolver.unquote("\"blah\\\\blah\""));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_failsOnMisspelling() {
+ JsonObject bad = new JsonObject();
+ bad.add("parcentage", new JsonPrimitive(1));
+ thrown.expectMessage("Bad key");
+
+ DnsNameResolver.maybeChooseServiceConfig(bad, new Random(), "host");
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_clientLanguageMatchesJava() {
+ JsonObject choice = new JsonObject();
+ JsonArray langs = new JsonArray();
+ langs.add("java");
+ choice.add("clientLanguage", langs);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_clientLanguageDoesntMatchGo() {
+ JsonObject choice = new JsonObject();
+ JsonArray langs = new JsonArray();
+ langs.add("go");
+ choice.add("clientLanguage", langs);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_clientLanguageCaseInsensitive() {
+ JsonObject choice = new JsonObject();
+ JsonArray langs = new JsonArray();
+ langs.add("JAVA");
+ choice.add("clientLanguage", langs);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_clientLanguageMatchesEmtpy() {
+ JsonObject choice = new JsonObject();
+ JsonArray langs = new JsonArray();
+ choice.add("clientLanguage", langs);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_clientLanguageMatchesMulti() {
+ JsonObject choice = new JsonObject();
+ JsonArray langs = new JsonArray();
+ langs.add("go");
+ langs.add("java");
+ choice.add("clientLanguage", langs);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_percentageZeroAlwaysFails() {
+ JsonObject choice = new JsonObject();
+ choice.add("percentage", new JsonPrimitive(0));
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_percentageHundredAlwaysSucceeds() {
+ JsonObject choice = new JsonObject();
+ choice.add("percentage", new JsonPrimitive(100));
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_percentageAboveMatches() {
+ JsonObject choice = new JsonObject();
+ choice.add("percentage", new JsonPrimitive(50));
+ choice.add("serviceConfig", new JsonObject());
+
+ Random r = new Random() {
+ @Override
+ public int nextInt(int bound) {
+ return 49;
+ }
+ };
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_percentageAtMatches() {
+ JsonObject choice = new JsonObject();
+ choice.add("percentage", new JsonPrimitive(50));
+ choice.add("serviceConfig", new JsonObject());
+
+ Random r = new Random() {
+ @Override
+ public int nextInt(int bound) {
+ return 50;
+ }
+ };
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_percentageBelowFails() {
+ JsonObject choice = new JsonObject();
+ choice.add("percentage", new JsonPrimitive(50));
+ choice.add("serviceConfig", new JsonObject());
+
+ Random r = new Random() {
+ @Override
+ public int nextInt(int bound) {
+ return 51;
+ }
+ };
+
+ assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, r, "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_hostnameMatches() {
+ JsonObject choice = new JsonObject();
+ JsonArray hosts = new JsonArray();
+ hosts.add("localhost");
+ choice.add("clientHostname", hosts);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_hostnameDoesntMatch() {
+ JsonObject choice = new JsonObject();
+ JsonArray hosts = new JsonArray();
+ hosts.add("localhorse");
+ choice.add("clientHostname", hosts);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_clientLanguageCaseSensitive() {
+ JsonObject choice = new JsonObject();
+ JsonArray hosts = new JsonArray();
+ hosts.add("LOCALHOST");
+ choice.add("clientHostname", hosts);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_hostnameMatchesEmtpy() {
+ JsonObject choice = new JsonObject();
+ JsonArray hosts = new JsonArray();
+ choice.add("clientHostname", hosts);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "host"));
+ }
+
+ @Test
+ public void maybeChooseServiceConfig_hostnameMatchesMulti() {
+ JsonObject choice = new JsonObject();
+ JsonArray langs = new JsonArray();
+ langs.add("localhorse");
+ langs.add("localhost");
+ choice.add("clientHostname", langs);
+ choice.add("serviceConfig", new JsonObject());
+
+ assertNotNull(DnsNameResolver.maybeChooseServiceConfig(choice, new Random(), "localhost"));
+ }
+
private void testInvalidUri(URI uri) {
try {
provider.newNameResolver(uri, NAME_RESOLVER_PARAMS);