diff --git a/xds/src/main/java/io/grpc/xds/XdsNameResolver.java b/xds/src/main/java/io/grpc/xds/XdsNameResolver.java new file mode 100644 index 0000000000..f71478570f --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/XdsNameResolver.java @@ -0,0 +1,92 @@ +/* + * Copyright 2019 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Preconditions; +import io.grpc.Attributes; +import io.grpc.EquivalentAddressGroup; +import io.grpc.NameResolver; +import io.grpc.Status; +import io.grpc.internal.GrpcAttributes; +import io.grpc.internal.JsonParser; +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.Map; + +/** + * A {@link NameResolver} for resolving gRPC target names with "xds-experimental" scheme. + * + *

The implementation is for load balancing alpha release only. No actual VHDS is involved. It + * always returns a hard-coded service config that selects the xds_experimental LB policy with + * round-robin as the child policy. + * + * @see XdsNameResolverProvider + */ +final class XdsNameResolver extends NameResolver { + + private static final String SERVICE_CONFIG_HARDCODED = "{" + + "\"loadBalancingConfig\": [" + + "{\"xds_experimental\" : {" + + "\"childPolicy\" : [{\"round_robin\" : {}}]" + + "}}" + + "]}"; + + private final String authority; + + XdsNameResolver(String name) { + URI nameUri = URI.create("//" + checkNotNull(name, "name")); + Preconditions.checkArgument(nameUri.getHost() != null, "Invalid hostname: %s", name); + authority = + Preconditions.checkNotNull( + nameUri.getAuthority(), "nameUri (%s) doesn't have an authority", nameUri); + } + + @Override + public String getServiceAuthority() { + return authority; + } + + @SuppressWarnings("unchecked") + @Override + public void start(final Listener2 listener) { + Map config; + try { + config = (Map) JsonParser.parse(SERVICE_CONFIG_HARDCODED); + } catch (IOException e) { + listener.onError( + Status.UNKNOWN.withDescription("Invalid service config").withCause(e)); + throw new AssertionError("Invalid service config"); + } + Attributes attrs = + Attributes.newBuilder() + .set(GrpcAttributes.NAME_RESOLVER_SERVICE_CONFIG, config) + .build(); + ResolutionResult result = + ResolutionResult.newBuilder() + .setAddresses(Collections.emptyList()) + .setAttributes(attrs) + .build(); + listener.onResult(result); + } + + @Override + public void shutdown() { + } +} diff --git a/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java b/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java new file mode 100644 index 0000000000..60b191b137 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/XdsNameResolverProvider.java @@ -0,0 +1,70 @@ +/* + * Copyright 2019 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds; + +import com.google.common.base.Preconditions; +import io.grpc.NameResolver.Args; +import io.grpc.NameResolverProvider; +import java.net.URI; + +/** + * A provider for {@link XdsNameResolver}. + * + *

It resolves a target URI whose scheme is {@code "xds-experimental"}. The authority of the + * target URI is never used for current release. The path of the target URI, excluding the leading + * slash {@code '/'}, will indicate the name to use in the VHDS query. + * + *

This class should not be directly referenced in code. The resolver should be accessed + * through {@link io.grpc.NameResolverRegistry#asFactory#newNameResolver(URI, Args)} with the URI + * scheme "xds-experimental". + */ +public final class XdsNameResolverProvider extends NameResolverProvider { + + private static final String SCHEME = "xds-experimental"; + + @Override + public XdsNameResolver newNameResolver(URI targetUri, Args args) { + if (SCHEME.equals(targetUri.getScheme())) { + String targetPath = Preconditions.checkNotNull(targetUri.getPath(), "targetPath"); + Preconditions.checkArgument( + targetPath.startsWith("/"), + "the path component (%s) of the target (%s) must start with '/'", + targetPath, + targetUri); + String name = targetPath.substring(1); + return new XdsNameResolver(name); + } + return null; + } + + @Override + public String getDefaultScheme() { + return SCHEME; + } + + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + // Set priority value to be < 5 as we still want DNS resolver to be the primary default + // resolver. + return 4; + } +} diff --git a/xds/src/main/resources/META-INF/services/io.grpc.NameResolverProvider b/xds/src/main/resources/META-INF/services/io.grpc.NameResolverProvider new file mode 100644 index 0000000000..269cdd3880 --- /dev/null +++ b/xds/src/main/resources/META-INF/services/io.grpc.NameResolverProvider @@ -0,0 +1 @@ +io.grpc.xds.XdsNameResolverProvider diff --git a/xds/src/test/java/io/grpc/xds/XdsNamResolverTest.java b/xds/src/test/java/io/grpc/xds/XdsNamResolverTest.java new file mode 100644 index 0000000000..cc2ea47981 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/XdsNamResolverTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 2019 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.google.common.collect.Iterables; +import io.grpc.NameResolver; +import io.grpc.NameResolver.ResolutionResult; +import io.grpc.NameResolver.ServiceConfigParser; +import io.grpc.SynchronizationContext; +import io.grpc.internal.GrpcAttributes; +import io.grpc.internal.GrpcUtil; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link XdsNameResolver}. */ +@RunWith(JUnit4.class) +public class XdsNamResolverTest { + + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + + private final SynchronizationContext syncContext = new SynchronizationContext( + new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + throw new AssertionError(e); + } + }); + + private final NameResolver.Args args = + NameResolver.Args.newBuilder() + .setDefaultPort(8080) + .setProxyDetector(GrpcUtil.NOOP_PROXY_DETECTOR) + .setSynchronizationContext(syncContext) + .setServiceConfigParser(mock(ServiceConfigParser.class)) + .build(); + + private final XdsNameResolverProvider provider = new XdsNameResolverProvider(); + + @Mock private NameResolver.Listener2 mockListener; + @Captor private ArgumentCaptor resultCaptor; + + @Test + public void validName_withAuthority() { + XdsNameResolver resolver = + provider.newNameResolver( + URI.create("xds-experimental://trafficdirector.google.com/foo.googleapis.com"), args); + assertThat(resolver).isNotNull(); + assertThat(resolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); + } + + @Test + public void validName_noAuthority() { + XdsNameResolver resolver = + provider.newNameResolver(URI.create("xds-experimental:///foo.googleapis.com"), args); + assertThat(resolver).isNotNull(); + assertThat(resolver.getServiceAuthority()).isEqualTo("foo.googleapis.com"); + } + + @Test + public void invalidName_hostnameContainsUnderscore() { + try { + provider.newNameResolver(URI.create("xds-experimental:///foo_bar.googleapis.com"), args); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + // Expected + } + } + + @Test + public void resolve_hardcodedResult() { + XdsNameResolver resolver = newResolver("foo.googleapis.com"); + resolver.start(mockListener); + verify(mockListener).onResult(resultCaptor.capture()); + assertHardCodedServiceConfig(resultCaptor.getValue()); + + resolver = newResolver("bar.googleapis.com"); + resolver.start(mockListener); + verify(mockListener, times(2)).onResult(resultCaptor.capture()); + assertHardCodedServiceConfig(resultCaptor.getValue()); + } + + @SuppressWarnings("unchecked") + private static void assertHardCodedServiceConfig(ResolutionResult actualResult) { + assertThat(actualResult.getAddresses()).isEmpty(); + Map serviceConfig = + actualResult.getAttributes().get(GrpcAttributes.NAME_RESOLVER_SERVICE_CONFIG); + List> rawLbConfigs = + (List>) serviceConfig.get("loadBalancingConfig"); + Map xdsLbConfig = Iterables.getOnlyElement(rawLbConfigs); + assertThat(xdsLbConfig.keySet()).containsExactly("xds_experimental"); + Map rawConfigValues = (Map) xdsLbConfig.get("xds_experimental"); + assertThat(rawConfigValues) + .containsExactly("childPolicy", + Collections.singletonList( + Collections.singletonMap("round_robin", Collections.EMPTY_MAP))); + } + + private XdsNameResolver newResolver(String name) { + return new XdsNameResolver(name); + } +} diff --git a/xds/src/test/java/io/grpc/xds/XdsNameResolverProviderTest.java b/xds/src/test/java/io/grpc/xds/XdsNameResolverProviderTest.java new file mode 100644 index 0000000000..549c3c66a8 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/XdsNameResolverProviderTest.java @@ -0,0 +1,83 @@ +/* + * Copyright 2019 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.xds; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; + +import io.grpc.InternalServiceProviders; +import io.grpc.NameResolver; +import io.grpc.NameResolver.ServiceConfigParser; +import io.grpc.NameResolverProvider; +import io.grpc.SynchronizationContext; +import io.grpc.internal.GrpcUtil; +import java.net.URI; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link XdsNameResolverProvider}. */ +@RunWith(JUnit4.class) +public class XdsNameResolverProviderTest { + private final SynchronizationContext syncContext = new SynchronizationContext( + new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + throw new AssertionError(e); + } + }); + private final NameResolver.Args args = NameResolver.Args.newBuilder() + .setDefaultPort(8080) + .setProxyDetector(GrpcUtil.NOOP_PROXY_DETECTOR) + .setSynchronizationContext(syncContext) + .setServiceConfigParser(mock(ServiceConfigParser.class)) + .build(); + + private XdsNameResolverProvider provider = new XdsNameResolverProvider(); + + @Test + public void provided() { + for (NameResolverProvider current + : InternalServiceProviders.getCandidatesViaServiceLoader( + NameResolverProvider.class, getClass().getClassLoader())) { + if (current instanceof XdsNameResolverProvider) { + return; + } + } + fail("XdsNameResolverProvider not registered"); + } + + @Test + public void isAvailable() { + assertThat(provider.isAvailable()).isTrue(); + } + + @Test + public void newNameResolver() { + assertThat( + provider.newNameResolver(URI.create("xds-experimental://1.1.1.1/foo.googleapis.com"), args)) + .isInstanceOf(XdsNameResolver.class); + assertThat( + provider.newNameResolver(URI.create("xds-experimental:///foo.googleapis.com"), args)) + .isInstanceOf(XdsNameResolver.class); + assertThat( + provider.newNameResolver(URI.create("notxds-experimental://1.1.1.1/foo.googleapis.com"), + args)) + .isNull(); + } +}