From fb0f7891310897f3a64aeeef17e88653f9c94cc9 Mon Sep 17 00:00:00 2001 From: Pradeep Date: Tue, 8 Oct 2024 02:21:42 +0200 Subject: [PATCH] feat: added custom grpc resolver added custom gRPC resolver to support envoy proxy Signed-off-by: Pradeep Mishra --- .../contrib/providers/flagd/Config.java | 1 + .../contrib/providers/flagd/FlagdOptions.java | 12 ++++ .../flagd/resolver/common/ChannelBuilder.java | 58 +++++++++++++++- .../common/GenericConfigException.java | 11 +++ .../common/nameresolvers/EnvoyResolver.java | 69 +++++++++++++++++++ .../nameresolvers/EnvoyResolverProvider.java | 44 ++++++++++++ .../providers/flagd/FlagdOptionsTest.java | 10 +++ .../EnvoyResolverProviderTest.java | 46 +++++++++++++ .../nameresolvers/EnvoyResolverTest.java | 16 +++++ .../resolver/grpc/GrpcConnectorTest.java | 10 +-- 10 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/GenericConfigException.java create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolver.java create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverProvider.java create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverProviderTest.java create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverTest.java diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/Config.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/Config.java index a2f9a1c9b..83aa6099e 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/Config.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/Config.java @@ -37,6 +37,7 @@ public final class Config { static final String OFFLINE_SOURCE_PATH = "FLAGD_OFFLINE_FLAG_SOURCE_PATH"; static final String KEEP_ALIVE_MS_ENV_VAR_NAME_OLD = "FLAGD_KEEP_ALIVE_TIME"; static final String KEEP_ALIVE_MS_ENV_VAR_NAME = "FLAGD_KEEP_ALIVE_TIME_MS"; + static final String GRPC_TARGET_ENV_VAR_NAME = "FLAGD_GRPC_TARGET"; static final String RESOLVER_RPC = "rpc"; static final String RESOLVER_IN_PROCESS = "in-process"; diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java index 0c76e61d9..83516aa86 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java @@ -123,6 +123,18 @@ public class FlagdOptions { @Builder.Default private String offlineFlagSourcePath = fallBackToEnvOrDefault(Config.OFFLINE_SOURCE_PATH, null); + + /** + * gRPC custom target string. + *

+ * Setting this will allow user to use custom gRPC name resolver at present + * we are supporting all core resolver along with a custom resolver for envoy proxy + * resolution. For more visit (https://grpc.io/docs/guides/custom-name-resolution/) + */ + @Builder.Default + private String targetUri = fallBackToEnvOrDefault(Config.GRPC_TARGET_ENV_VAR_NAME, null); + + /** * Function providing an EvaluationContext to mix into every evaluations. * The sync-metadata response diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ChannelBuilder.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ChannelBuilder.java index e5c94e21d..3c2174898 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ChannelBuilder.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ChannelBuilder.java @@ -1,8 +1,10 @@ package dev.openfeature.contrib.providers.flagd.resolver.common; import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers.EnvoyResolverProvider; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import io.grpc.ManagedChannel; +import io.grpc.NameResolverRegistry; import io.grpc.netty.GrpcSslContexts; import io.grpc.netty.NettyChannelBuilder; import io.netty.channel.epoll.Epoll; @@ -13,6 +15,8 @@ import javax.net.ssl.SSLException; import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; import java.util.concurrent.TimeUnit; /** @@ -50,9 +54,21 @@ public static ManagedChannel nettyChannel(final FlagdOptions options) { // build a TCP socket try { + // Register custom resolver + if (isEnvoyTarget(options.getTargetUri())) { + NameResolverRegistry.getDefaultRegistry().register(new EnvoyResolverProvider()); + } + + // default to current `dns` resolution i.e. :, if valid / supported + // target string use the user provided target uri. + final String defaultTarget = String.format("%s:%s", options.getHost(), options.getPort()); + final String targetUri = isValidTargetUri(options.getTargetUri()) ? options.getTargetUri() : + defaultTarget; + final NettyChannelBuilder builder = NettyChannelBuilder - .forAddress(options.getHost(), options.getPort()) + .forTarget(targetUri) .keepAliveTime(keepAliveMs, TimeUnit.MILLISECONDS); + if (options.isTls()) { SslContextBuilder sslContext = GrpcSslContexts.forClient(); @@ -78,6 +94,46 @@ public static ManagedChannel nettyChannel(final FlagdOptions options) { SslConfigException sslConfigException = new SslConfigException("Error with SSL configuration."); sslConfigException.initCause(ssle); throw sslConfigException; + } catch (IllegalArgumentException argumentException) { + GenericConfigException genericConfigException = new GenericConfigException("Error with gRPC target string " + + "configuration"); + genericConfigException.initCause(argumentException); + throw genericConfigException; + } + } + + private static boolean isValidTargetUri(String targetUri) { + if (targetUri == null) { + return false; + } + + try { + final String scheme = new URI(targetUri).getScheme(); + if (scheme.equals("envoy") || scheme.equals("dns") || scheme.equals("xds") || scheme.equals("uds")) { + return true; + } + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid target string", e); } + + return false; + } + + private static boolean isEnvoyTarget(String targetUri) { + if (targetUri == null) { + return false; + } + + try { + final String scheme = new URI(targetUri).getScheme(); + if (scheme.equals("envoy")) { + return true; + } + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid target string", e); + } + + return false; + } } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/GenericConfigException.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/GenericConfigException.java new file mode 100644 index 000000000..8bce152b2 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/GenericConfigException.java @@ -0,0 +1,11 @@ +package dev.openfeature.contrib.providers.flagd.resolver.common; + +/** + * Custom exception for invalid gRPC configurations. + */ + +public class GenericConfigException extends RuntimeException { + public GenericConfigException(String message) { + super(message); + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolver.java new file mode 100644 index 000000000..128cd8c1a --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolver.java @@ -0,0 +1,69 @@ +package dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers; + +import io.grpc.EquivalentAddressGroup; +import io.grpc.NameResolver; +import java.net.InetSocketAddress; +import io.grpc.Attributes; +import io.grpc.Status; +import java.net.URI; +import java.util.Collections; +import java.util.List; + +/** + * Envoy NameResolver, will always override the authority with the specified authority and + * use the socketAddress to connect. + *

+ * Custom URI Scheme: + *

+ * envoy://[proxy-agent-host]:[proxy-agent-port]/[service-name] + *

+ * `service-name` is used as authority instead host + */ +public class EnvoyResolver extends NameResolver { + private final URI uri; + private final String authority; + private Listener2 listener; + + public EnvoyResolver(URI targetUri) { + this.uri = targetUri; + this.authority = targetUri.getPath().substring(1); + } + + @Override + public String getServiceAuthority() { + return authority; + } + + @Override + public void shutdown() { + } + + @Override + public void start(Listener2 listener) { + this.listener = listener; + this.resolve(); + } + + @Override + public void refresh() { + this.resolve(); + } + + private void resolve() { + try { + InetSocketAddress address = new InetSocketAddress(this.uri.getHost(), this.uri.getPort()); + Attributes addressGroupAttributes = Attributes.newBuilder() + .set(EquivalentAddressGroup.ATTR_AUTHORITY_OVERRIDE, this.authority) + .build(); + List equivalentAddressGroup = Collections.singletonList( + new EquivalentAddressGroup(address, addressGroupAttributes) + ); + ResolutionResult resolutionResult = ResolutionResult.newBuilder() + .setAddresses(equivalentAddressGroup) + .build(); + this.listener.onResult(resolutionResult); + } catch (Exception e) { + this.listener.onError(Status.UNAVAILABLE.withDescription("Unable to resolve host ").withCause(e)); + } + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverProvider.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverProvider.java new file mode 100644 index 000000000..c79a372ee --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverProvider.java @@ -0,0 +1,44 @@ +package dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers; + +import io.grpc.NameResolver; +import io.grpc.NameResolverProvider; +import java.net.URI; + +public class EnvoyResolverProvider extends NameResolverProvider { + static final String ENVOY_SCHEME = "envoy"; + static final String DEFAULT_SERVICE_NAME = "undefined"; + @Override + protected boolean isAvailable() { + return true; + } + + @Override + protected int priority() { + return 6; + } + + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + if (!ENVOY_SCHEME.equals(targetUri.getScheme())) { + return null; + } + + if (!isValidPath(targetUri.getPath()) || targetUri.getHost() == null || targetUri.getPort() == -1 ) { + throw new IllegalArgumentException("Incorrectly formatted target uri; " + + "expected: '" + ENVOY_SCHEME + ":[//]:/';" + + "but was '" + targetUri + "'"); + } + + return new EnvoyResolver(targetUri); + } + + @Override + public String getDefaultScheme() { + return ENVOY_SCHEME; + } + + private static boolean isValidPath(String path) { + return !path.isEmpty() && !path.substring(1).isEmpty() + && !path.substring(1).contains("/"); + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java index 081193c22..acaa66a56 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java @@ -54,6 +54,7 @@ void TestBuilderOptions() { .openTelemetry(openTelemetry) .customConnector(connector) .resolverType(Resolver.IN_PROCESS) + .targetUri("dns:///localhost:8016") .keepAlive(1000) .build(); @@ -69,6 +70,7 @@ void TestBuilderOptions() { assertEquals(openTelemetry, flagdOptions.getOpenTelemetry()); assertEquals(connector, flagdOptions.getCustomConnector()); assertEquals(Resolver.IN_PROCESS, flagdOptions.getResolverType()); + assertEquals("dns:///localhost:8016", flagdOptions.getTargetUri()); assertEquals(1000, flagdOptions.getKeepAlive()); } @@ -187,4 +189,12 @@ void testRpcProviderFromEnv_portConfigured_usesConfiguredPort() { assertThat(flagdOptions.getPort()).isEqualTo(1534); } + + @Test + @SetEnvironmentVariable(key = GRPC_TARGET_ENV_VAR_NAME, value = "envoy://localhost:1234/foo.service") + void testTargetOverrideFromEnv() { + FlagdOptions flagdOptions = FlagdOptions.builder().build(); + + assertThat(flagdOptions.getTargetUri()).isEqualTo("envoy://localhost:1234/foo.service"); + } } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverProviderTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverProviderTest.java new file mode 100644 index 000000000..271fb281e --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverProviderTest.java @@ -0,0 +1,46 @@ +package dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.stream.Stream; + +class EnvoyResolverProviderTest { + private final EnvoyResolverProvider provider = new EnvoyResolverProvider(); + + @Test + void envoyProviderTestScheme() { + Assertions.assertTrue(provider.isAvailable()); + Assertions.assertNotNull(provider.newNameResolver(URI.create("envoy://localhost:1234/foo.service"), + null)); + Assertions.assertNull(provider.newNameResolver(URI.create("invalid-scheme://localhost:1234/foo.service"), + null)); + } + + + @ParameterizedTest + @MethodSource("getInvalidPaths") + void invalidTargetUriTests(String mockUri) { + Exception exception = Assertions.assertThrows(IllegalArgumentException.class, () -> { + provider.newNameResolver(URI.create(mockUri), null); + }); + + Assertions.assertTrue(exception.toString().contains("Incorrectly formatted target uri")); + } + + private static Stream getInvalidPaths() { + return Stream.of( + Arguments.of("envoy://localhost:1234/test.service/test"), + Arguments.of("envoy://localhost:1234/"), + Arguments.of("envoy://localhost:1234"), + Arguments.of("envoy://localhost/test.service"), + Arguments.of("envoy:///test.service") + ); + } + +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverTest.java new file mode 100644 index 000000000..c02ddf8a2 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/common/nameresolvers/EnvoyResolverTest.java @@ -0,0 +1,16 @@ +package dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import java.net.URI; + +class EnvoyResolverTest { + @Test + void envoyResolverTest() { + // given + EnvoyResolver envoyResolver = new EnvoyResolver(URI.create("envoy://localhost:1234/foo.service")); + + // then + Assertions.assertEquals("foo.service", envoyResolver.getServiceAuthority()); + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnectorTest.java index b6409d3c7..dffac542b 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnectorTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnectorTest.java @@ -192,6 +192,7 @@ void stream_does_not_fail_with_deadline_error() throws Exception { void host_and_port_arg_should_build_tcp_socket() { final String host = "host.com"; final int port = 1234; + final String targetUri = String.format("%s:%s", host, port); ServiceGrpc.ServiceBlockingStub mockBlockingStub = mock(ServiceGrpc.ServiceBlockingStub.class); ServiceGrpc.ServiceStub mockStub = createServiceStubMock(); @@ -206,14 +207,14 @@ void host_and_port_arg_should_build_tcp_socket() { try (MockedStatic mockStaticChannelBuilder = mockStatic(NettyChannelBuilder.class)) { mockStaticChannelBuilder.when(() -> NettyChannelBuilder - .forAddress(anyString(), anyInt())).thenReturn(mockChannelBuilder); + .forTarget(anyString())).thenReturn(mockChannelBuilder); final FlagdOptions flagdOptions = FlagdOptions.builder().host(host).port(port).tls(false).build(); new GrpcConnector(flagdOptions, null, null, null); // verify host/port matches mockStaticChannelBuilder.verify(() -> NettyChannelBuilder - .forAddress(host, port), times(1)); + .forTarget(String.format(targetUri)), times(1)); } } } @@ -222,6 +223,7 @@ void host_and_port_arg_should_build_tcp_socket() { void no_args_host_and_port_env_set_should_build_tcp_socket() throws Exception { final String host = "server.com"; final int port = 4321; + final String targetUri = String.format("%s:%s", host, port); new EnvironmentVariables("FLAGD_HOST", host, "FLAGD_PORT", String.valueOf(port)).execute(() -> { ServiceGrpc.ServiceBlockingStub mockBlockingStub = mock(ServiceGrpc.ServiceBlockingStub.class); @@ -238,12 +240,12 @@ void no_args_host_and_port_env_set_should_build_tcp_socket() throws Exception { NettyChannelBuilder.class)) { mockStaticChannelBuilder.when(() -> NettyChannelBuilder - .forAddress(anyString(), anyInt())).thenReturn(mockChannelBuilder); + .forTarget(anyString())).thenReturn(mockChannelBuilder); new GrpcConnector(FlagdOptions.builder().build(), null, null, null); // verify host/port matches & called times(= 1 as we rely on reusable channel) - mockStaticChannelBuilder.verify(() -> NettyChannelBuilder.forAddress(host, port), times(1)); + mockStaticChannelBuilder.verify(() -> NettyChannelBuilder.forTarget(targetUri), times(1)); } } });