Skip to content

Commit 0da27d8

Browse files
committed
feat: added custom grpc resolver
added custom gRPC resolver to support envoy proxy Signed-off-by: Pradeep Mishra <[email protected]> Signed-off-by: Pradeep <[email protected]>
1 parent 8c6e0ad commit 0da27d8

File tree

10 files changed

+272
-5
lines changed

10 files changed

+272
-5
lines changed

providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/Config.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public final class Config {
3737
static final String OFFLINE_SOURCE_PATH = "FLAGD_OFFLINE_FLAG_SOURCE_PATH";
3838
static final String KEEP_ALIVE_MS_ENV_VAR_NAME_OLD = "FLAGD_KEEP_ALIVE_TIME";
3939
static final String KEEP_ALIVE_MS_ENV_VAR_NAME = "FLAGD_KEEP_ALIVE_TIME_MS";
40+
static final String GRPC_TARGET_ENV_VAR_NAME = "FLAGD_GRPC_TARGET";
4041

4142
static final String RESOLVER_RPC = "rpc";
4243
static final String RESOLVER_IN_PROCESS = "in-process";

providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdOptions.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ public class FlagdOptions {
123123
@Builder.Default
124124
private String offlineFlagSourcePath = fallBackToEnvOrDefault(Config.OFFLINE_SOURCE_PATH, null);
125125

126+
127+
/**
128+
* gRPC custom target string.
129+
* <p>
130+
* Setting this will allow user to use custom gRPC name resolver at present
131+
* we are supporting all core resolver along with a custom resolver for envoy proxy
132+
* resolution. For more visit (https://grpc.io/docs/guides/custom-name-resolution/)
133+
*/
134+
@Builder.Default
135+
private String targetUri = fallBackToEnvOrDefault(Config.GRPC_TARGET_ENV_VAR_NAME, null);
136+
137+
126138
/**
127139
* Function providing an EvaluationContext to mix into every evaluations.
128140
* The sync-metadata response

providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/common/ChannelBuilder.java

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package dev.openfeature.contrib.providers.flagd.resolver.common;
22

33
import dev.openfeature.contrib.providers.flagd.FlagdOptions;
4+
import dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers.EnvoyResolverProvider;
45
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
56
import io.grpc.ManagedChannel;
7+
import io.grpc.NameResolverRegistry;
68
import io.grpc.netty.GrpcSslContexts;
79
import io.grpc.netty.NettyChannelBuilder;
810
import io.netty.channel.epoll.Epoll;
@@ -13,6 +15,8 @@
1315

1416
import javax.net.ssl.SSLException;
1517
import java.io.File;
18+
import java.net.URI;
19+
import java.net.URISyntaxException;
1620
import java.util.concurrent.TimeUnit;
1721

1822
/**
@@ -50,9 +54,21 @@ public static ManagedChannel nettyChannel(final FlagdOptions options) {
5054

5155
// build a TCP socket
5256
try {
57+
// Register custom resolver
58+
if (isEnvoyTarget(options.getTargetUri())) {
59+
NameResolverRegistry.getDefaultRegistry().register(new EnvoyResolverProvider());
60+
}
61+
62+
// default to current `dns` resolution i.e. <host>:<port>, if valid / supported
63+
// target string use the user provided target uri.
64+
final String defaultTarget = String.format("%s:%s", options.getHost(), options.getPort());
65+
final String targetUri = isValidTargetUri(options.getTargetUri()) ? options.getTargetUri() :
66+
defaultTarget;
67+
5368
final NettyChannelBuilder builder = NettyChannelBuilder
54-
.forAddress(options.getHost(), options.getPort())
69+
.forTarget(targetUri)
5570
.keepAliveTime(keepAliveMs, TimeUnit.MILLISECONDS);
71+
5672
if (options.isTls()) {
5773
SslContextBuilder sslContext = GrpcSslContexts.forClient();
5874

@@ -78,6 +94,46 @@ public static ManagedChannel nettyChannel(final FlagdOptions options) {
7894
SslConfigException sslConfigException = new SslConfigException("Error with SSL configuration.");
7995
sslConfigException.initCause(ssle);
8096
throw sslConfigException;
97+
} catch (IllegalArgumentException argumentException) {
98+
GenericConfigException genericConfigException = new GenericConfigException("Error with gRPC target string " +
99+
"configuration");
100+
genericConfigException.initCause(argumentException);
101+
throw genericConfigException;
102+
}
103+
}
104+
105+
private static boolean isValidTargetUri(String targetUri) {
106+
if (targetUri == null) {
107+
return false;
108+
}
109+
110+
try {
111+
final String scheme = new URI(targetUri).getScheme();
112+
if (scheme.equals("envoy") || scheme.equals("dns") || scheme.equals("xds") || scheme.equals("uds")) {
113+
return true;
114+
}
115+
} catch (URISyntaxException e) {
116+
throw new IllegalArgumentException("Invalid target string", e);
81117
}
118+
119+
return false;
120+
}
121+
122+
private static boolean isEnvoyTarget(String targetUri) {
123+
if (targetUri == null) {
124+
return false;
125+
}
126+
127+
try {
128+
final String scheme = new URI(targetUri).getScheme();
129+
if (scheme.equals("envoy")) {
130+
return true;
131+
}
132+
} catch (URISyntaxException e) {
133+
throw new IllegalArgumentException("Invalid target string", e);
134+
}
135+
136+
return false;
137+
82138
}
83139
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package dev.openfeature.contrib.providers.flagd.resolver.common;
2+
3+
/**
4+
* Custom exception for invalid gRPC configurations.
5+
*/
6+
7+
public class GenericConfigException extends RuntimeException {
8+
public GenericConfigException(String message) {
9+
super(message);
10+
}
11+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers;
2+
3+
import io.grpc.EquivalentAddressGroup;
4+
import io.grpc.NameResolver;
5+
import java.net.InetSocketAddress;
6+
import io.grpc.Attributes;
7+
import io.grpc.Status;
8+
import java.net.URI;
9+
import java.util.Collections;
10+
import java.util.List;
11+
12+
/**
13+
* Envoy NameResolver, will always override the authority with the specified authority and
14+
* use the socketAddress to connect.
15+
* <p>
16+
* Custom URI Scheme:
17+
* <p>
18+
* envoy://[proxy-agent-host]:[proxy-agent-port]/[service-name]
19+
* <p>
20+
* `service-name` is used as authority instead host
21+
*/
22+
public class EnvoyResolver extends NameResolver {
23+
private final URI uri;
24+
private final String authority;
25+
private Listener2 listener;
26+
27+
public EnvoyResolver(URI targetUri) {
28+
this.uri = targetUri;
29+
this.authority = targetUri.getPath().substring(1);
30+
}
31+
32+
@Override
33+
public String getServiceAuthority() {
34+
return authority;
35+
}
36+
37+
@Override
38+
public void shutdown() {
39+
}
40+
41+
@Override
42+
public void start(Listener2 listener) {
43+
this.listener = listener;
44+
this.resolve();
45+
}
46+
47+
@Override
48+
public void refresh() {
49+
this.resolve();
50+
}
51+
52+
private void resolve() {
53+
try {
54+
InetSocketAddress address = new InetSocketAddress(this.uri.getHost(), this.uri.getPort());
55+
Attributes addressGroupAttributes = Attributes.newBuilder()
56+
.set(EquivalentAddressGroup.ATTR_AUTHORITY_OVERRIDE, this.authority)
57+
.build();
58+
List<EquivalentAddressGroup> equivalentAddressGroup = Collections.singletonList(
59+
new EquivalentAddressGroup(address, addressGroupAttributes)
60+
);
61+
ResolutionResult resolutionResult = ResolutionResult.newBuilder()
62+
.setAddresses(equivalentAddressGroup)
63+
.build();
64+
this.listener.onResult(resolutionResult);
65+
} catch (Exception e) {
66+
this.listener.onError(Status.UNAVAILABLE.withDescription("Unable to resolve host ").withCause(e));
67+
}
68+
}
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers;
2+
3+
import io.grpc.NameResolver;
4+
import io.grpc.NameResolverProvider;
5+
import java.net.URI;
6+
7+
public class EnvoyResolverProvider extends NameResolverProvider {
8+
static final String ENVOY_SCHEME = "envoy";
9+
static final String DEFAULT_SERVICE_NAME = "undefined";
10+
@Override
11+
protected boolean isAvailable() {
12+
return true;
13+
}
14+
15+
@Override
16+
protected int priority() {
17+
return 6;
18+
}
19+
20+
@Override
21+
public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) {
22+
if (!ENVOY_SCHEME.equals(targetUri.getScheme())) {
23+
return null;
24+
}
25+
26+
if (!isValidPath(targetUri.getPath()) || targetUri.getHost() == null || targetUri.getPort() == -1 ) {
27+
throw new IllegalArgumentException("Incorrectly formatted target uri; "
28+
+ "expected: '" + ENVOY_SCHEME + ":[//]<proxy-agent-host>:<proxy-agent-port>/<service-name>';"
29+
+ "but was '" + targetUri + "'");
30+
}
31+
32+
return new EnvoyResolver(targetUri);
33+
}
34+
35+
@Override
36+
public String getDefaultScheme() {
37+
return ENVOY_SCHEME;
38+
}
39+
40+
private static boolean isValidPath(String path) {
41+
return !path.isEmpty() && !path.substring(1).isEmpty()
42+
&& !path.substring(1).contains("/");
43+
}
44+
}

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdOptionsTest.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ void TestBuilderOptions() {
5454
.openTelemetry(openTelemetry)
5555
.customConnector(connector)
5656
.resolverType(Resolver.IN_PROCESS)
57+
.targetUri("dns:///localhost:8016")
5758
.keepAlive(1000)
5859
.build();
5960

@@ -69,6 +70,7 @@ void TestBuilderOptions() {
6970
assertEquals(openTelemetry, flagdOptions.getOpenTelemetry());
7071
assertEquals(connector, flagdOptions.getCustomConnector());
7172
assertEquals(Resolver.IN_PROCESS, flagdOptions.getResolverType());
73+
assertEquals("dns:///localhost:8016", flagdOptions.getTargetUri());
7274
assertEquals(1000, flagdOptions.getKeepAlive());
7375
}
7476

@@ -187,4 +189,12 @@ void testRpcProviderFromEnv_portConfigured_usesConfiguredPort() {
187189
assertThat(flagdOptions.getPort()).isEqualTo(1534);
188190

189191
}
192+
193+
@Test
194+
@SetEnvironmentVariable(key = GRPC_TARGET_ENV_VAR_NAME, value = "envoy://localhost:1234/foo.service")
195+
void testTargetOverrideFromEnv() {
196+
FlagdOptions flagdOptions = FlagdOptions.builder().build();
197+
198+
assertThat(flagdOptions.getTargetUri()).isEqualTo("envoy://localhost:1234/foo.service");
199+
}
190200
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers;
2+
3+
import org.junit.jupiter.api.Assertions;
4+
import org.junit.jupiter.api.Test;
5+
import org.junit.jupiter.params.ParameterizedTest;
6+
import org.junit.jupiter.params.provider.Arguments;
7+
import org.junit.jupiter.params.provider.MethodSource;
8+
9+
import java.net.URI;
10+
import java.net.URISyntaxException;
11+
import java.util.stream.Stream;
12+
13+
class EnvoyResolverProviderTest {
14+
private final EnvoyResolverProvider provider = new EnvoyResolverProvider();
15+
16+
@Test
17+
void envoyProviderTestScheme() {
18+
Assertions.assertTrue(provider.isAvailable());
19+
Assertions.assertNotNull(provider.newNameResolver(URI.create("envoy://localhost:1234/foo.service"),
20+
null));
21+
Assertions.assertNull(provider.newNameResolver(URI.create("invalid-scheme://localhost:1234/foo.service"),
22+
null));
23+
}
24+
25+
26+
@ParameterizedTest
27+
@MethodSource("getInvalidPaths")
28+
void invalidTargetUriTests(String mockUri) {
29+
Exception exception = Assertions.assertThrows(IllegalArgumentException.class, () -> {
30+
provider.newNameResolver(URI.create(mockUri), null);
31+
});
32+
33+
Assertions.assertTrue(exception.toString().contains("Incorrectly formatted target uri"));
34+
}
35+
36+
private static Stream<Arguments> getInvalidPaths() {
37+
return Stream.of(
38+
Arguments.of("envoy://localhost:1234/test.service/test"),
39+
Arguments.of("envoy://localhost:1234/"),
40+
Arguments.of("envoy://localhost:1234"),
41+
Arguments.of("envoy://localhost/test.service"),
42+
Arguments.of("envoy:///test.service")
43+
);
44+
}
45+
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package dev.openfeature.contrib.providers.flagd.resolver.common.nameresolvers;
2+
3+
import org.junit.jupiter.api.Assertions;
4+
import org.junit.jupiter.api.Test;
5+
import java.net.URI;
6+
7+
class EnvoyResolverTest {
8+
@Test
9+
void envoyResolverTest() {
10+
// given
11+
EnvoyResolver envoyResolver = new EnvoyResolver(URI.create("envoy://localhost:1234/foo.service"));
12+
13+
// then
14+
Assertions.assertEquals("foo.service", envoyResolver.getServiceAuthority());
15+
}
16+
}

providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/grpc/GrpcConnectorTest.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ void stream_does_not_fail_with_deadline_error() throws Exception {
192192
void host_and_port_arg_should_build_tcp_socket() {
193193
final String host = "host.com";
194194
final int port = 1234;
195+
final String targetUri = String.format("%s:%s", host, port);
195196

196197
ServiceGrpc.ServiceBlockingStub mockBlockingStub = mock(ServiceGrpc.ServiceBlockingStub.class);
197198
ServiceGrpc.ServiceStub mockStub = createServiceStubMock();
@@ -206,14 +207,14 @@ void host_and_port_arg_should_build_tcp_socket() {
206207
try (MockedStatic<NettyChannelBuilder> mockStaticChannelBuilder = mockStatic(NettyChannelBuilder.class)) {
207208

208209
mockStaticChannelBuilder.when(() -> NettyChannelBuilder
209-
.forAddress(anyString(), anyInt())).thenReturn(mockChannelBuilder);
210+
.forTarget(anyString())).thenReturn(mockChannelBuilder);
210211

211212
final FlagdOptions flagdOptions = FlagdOptions.builder().host(host).port(port).tls(false).build();
212213
new GrpcConnector(flagdOptions, null, null, null);
213214

214215
// verify host/port matches
215216
mockStaticChannelBuilder.verify(() -> NettyChannelBuilder
216-
.forAddress(host, port), times(1));
217+
.forTarget(String.format(targetUri)), times(1));
217218
}
218219
}
219220
}
@@ -222,6 +223,7 @@ void host_and_port_arg_should_build_tcp_socket() {
222223
void no_args_host_and_port_env_set_should_build_tcp_socket() throws Exception {
223224
final String host = "server.com";
224225
final int port = 4321;
226+
final String targetUri = String.format("%s:%s", host, port);
225227

226228
new EnvironmentVariables("FLAGD_HOST", host, "FLAGD_PORT", String.valueOf(port)).execute(() -> {
227229
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 {
238240
NettyChannelBuilder.class)) {
239241

240242
mockStaticChannelBuilder.when(() -> NettyChannelBuilder
241-
.forAddress(anyString(), anyInt())).thenReturn(mockChannelBuilder);
243+
.forTarget(anyString())).thenReturn(mockChannelBuilder);
242244

243245
new GrpcConnector(FlagdOptions.builder().build(), null, null, null);
244246

245247
// verify host/port matches & called times(= 1 as we rely on reusable channel)
246-
mockStaticChannelBuilder.verify(() -> NettyChannelBuilder.forAddress(host, port), times(1));
248+
mockStaticChannelBuilder.verify(() -> NettyChannelBuilder.forTarget(targetUri), times(1));
247249
}
248250
}
249251
});

0 commit comments

Comments
 (0)