xds: support case insensitive path matching (#7506)

This commit is contained in:
Chengyuan Zhang 2020-10-14 17:05:47 -07:00 committed by GitHub
parent 67b54608da
commit ef90da036d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 94 additions and 142 deletions

View File

@ -919,16 +919,6 @@ final class EnvoyProtoData {
return routeAction;
}
// TODO(chengyuanzhang): delete and do not use after routing feature is always ON.
boolean isDefaultRoute() {
// For backward compatibility, all the other matchers are ignored.
String prefix = routeMatch.getPathMatch().getPrefix();
if (prefix != null) {
return prefix.isEmpty() || prefix.equals("/");
}
return false;
}
@Override
public boolean equals(Object o) {
if (this == o) {
@ -993,17 +983,12 @@ final class EnvoyProtoData {
}
@VisibleForTesting
@SuppressWarnings("deprecation")
@Nullable
static StructOrError<RouteMatch> convertEnvoyProtoRouteMatch(
io.envoyproxy.envoy.config.route.v3.RouteMatch proto) {
if (proto.getQueryParametersCount() != 0) {
return null;
}
if (proto.hasCaseSensitive() && !proto.getCaseSensitive().getValue()) {
return StructOrError.fromError("Unsupported match option: case insensitive");
}
StructOrError<PathMatcher> pathMatch = convertEnvoyProtoPathMatcher(proto);
if (pathMatch.getErrorDetail() != null) {
return StructOrError.fromError(pathMatch.getErrorDetail());
@ -1033,32 +1018,28 @@ final class EnvoyProtoData {
pathMatch.getStruct(), Collections.unmodifiableList(headerMatchers), fractionMatch));
}
@SuppressWarnings("deprecation")
private static StructOrError<PathMatcher> convertEnvoyProtoPathMatcher(
io.envoyproxy.envoy.config.route.v3.RouteMatch proto) {
String path = null;
String prefix = null;
Pattern safeRegEx = null;
boolean caseSensitive = proto.getCaseSensitive().getValue();
switch (proto.getPathSpecifierCase()) {
case PREFIX:
prefix = proto.getPrefix();
break;
return StructOrError.fromStruct(
PathMatcher.fromPrefix(proto.getPrefix(), caseSensitive));
case PATH:
path = proto.getPath();
break;
return StructOrError.fromStruct(PathMatcher.fromPath(proto.getPath(), caseSensitive));
case SAFE_REGEX:
String rawPattern = proto.getSafeRegex().getRegex();
Pattern safeRegEx;
try {
safeRegEx = Pattern.compile(rawPattern);
} catch (PatternSyntaxException e) {
return StructOrError.fromError("Malformed safe regex pattern: " + e.getMessage());
}
break;
return StructOrError.fromStruct(PathMatcher.fromRegEx(safeRegEx));
case PATHSPECIFIER_NOT_SET:
default:
return StructOrError.fromError("Unknown path match type");
}
return StructOrError.fromStruct(new PathMatcher(path, prefix, safeRegEx));
}
private static StructOrError<FractionMatcher> convertEnvoyProtoFraction(

View File

@ -45,10 +45,8 @@ final class RouteMatch {
this.headerMatchers = headerMatchers;
}
@VisibleForTesting
RouteMatch(@Nullable String pathPrefixMatch, @Nullable String pathExactMatch) {
this(
new PathMatcher(pathExactMatch, pathPrefixMatch, null),
static RouteMatch withPathExactOnly(String pathExact) {
return new RouteMatch(PathMatcher.fromPath(pathExact, true),
Collections.<HeaderMatcher>emptyList(), null);
}
@ -82,19 +80,6 @@ final class RouteMatch {
return fractionMatch == null || fractionMatch.matches();
}
PathMatcher getPathMatch() {
return pathMatch;
}
List<HeaderMatcher> getHeaderMatchers() {
return Collections.unmodifiableList(headerMatchers);
}
@Nullable
FractionMatcher getFractionMatch() {
return fractionMatch;
}
@Override
public boolean equals(Object o) {
if (this == o) {
@ -132,37 +117,39 @@ final class RouteMatch {
private final String prefix;
@Nullable
private final Pattern regEx;
private final boolean caseSensitive;
PathMatcher(@Nullable String path, @Nullable String prefix, @Nullable Pattern regEx) {
private PathMatcher(@Nullable String path, @Nullable String prefix, @Nullable Pattern regEx,
boolean caseSensitive) {
this.path = path;
this.prefix = prefix;
this.regEx = regEx;
this.caseSensitive = caseSensitive;
}
private boolean matches(String fullMethodName) {
static PathMatcher fromPath(String path, boolean caseSensitive) {
return new PathMatcher(path, null, null, caseSensitive);
}
static PathMatcher fromPrefix(String prefix, boolean caseSensitive) {
return new PathMatcher(null, prefix, null, caseSensitive);
}
static PathMatcher fromRegEx(Pattern regEx) {
return new PathMatcher(null, null, regEx, false /* doesn't matter */);
}
boolean matches(String fullMethodName) {
if (path != null) {
return path.equals(fullMethodName);
return caseSensitive ? path.equals(fullMethodName) : path.equalsIgnoreCase(fullMethodName);
} else if (prefix != null) {
return fullMethodName.startsWith(prefix);
return caseSensitive
? fullMethodName.startsWith(prefix)
: fullMethodName.toLowerCase().startsWith(prefix.toLowerCase());
}
return regEx.matches(fullMethodName);
}
@Nullable
String getPath() {
return path;
}
@Nullable
String getPrefix() {
return prefix;
}
@Nullable
Pattern getRegEx() {
return regEx;
}
@Override
public boolean equals(Object o) {
if (this == o) {
@ -174,6 +161,7 @@ final class RouteMatch {
PathMatcher that = (PathMatcher) o;
return Objects.equals(path, that.path)
&& Objects.equals(prefix, that.prefix)
&& Objects.equals(caseSensitive, that.caseSensitive)
&& Objects.equals(
regEx == null ? null : regEx.pattern(),
that.regEx == null ? null : that.regEx.pattern());
@ -181,7 +169,7 @@ final class RouteMatch {
@Override
public int hashCode() {
return Objects.hash(path, prefix, regEx == null ? null : regEx.pattern());
return Objects.hash(path, prefix, caseSensitive, regEx == null ? null : regEx.pattern());
}
@Override
@ -189,10 +177,10 @@ final class RouteMatch {
ToStringHelper toStringHelper =
MoreObjects.toStringHelper(this);
if (path != null) {
toStringHelper.add("path", path);
toStringHelper.add("path", path).add("caseSensitive", caseSensitive);
}
if (prefix != null) {
toStringHelper.add("prefix", prefix);
toStringHelper.add("prefix", prefix).add("caseSensitive", caseSensitive);
}
if (regEx != null) {
toStringHelper.add("regEx", regEx.pattern());

View File

@ -50,7 +50,6 @@ import io.grpc.xds.RouteMatch.PathMatcher;
import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@ -211,7 +210,7 @@ public class EnvoyProtoDataTest {
assertThat(struct1.getStruct())
.isEqualTo(
new Route(
new RouteMatch(new PathMatcher("/service/method", null, null),
new RouteMatch(PathMatcher.fromPath("/service/method", false),
Collections.<HeaderMatcher>emptyList(), null),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), "cluster-foo", null)));
@ -259,38 +258,6 @@ public class EnvoyProtoDataTest {
assertThat(Route.fromEnvoyProtoRoute(proto)).isNull();
}
@Test
public void isDefaultRoute() {
StructOrError<Route> struct1 = Route.fromEnvoyProtoRoute(buildSimpleRouteProto("", null));
StructOrError<Route> struct2 = Route.fromEnvoyProtoRoute(buildSimpleRouteProto("/", null));
StructOrError<Route> struct3 =
Route.fromEnvoyProtoRoute(buildSimpleRouteProto("/service/", null));
StructOrError<Route> struct4 =
Route.fromEnvoyProtoRoute(buildSimpleRouteProto(null, "/service/method"));
assertThat(struct1.getStruct().isDefaultRoute()).isTrue();
assertThat(struct2.getStruct().isDefaultRoute()).isTrue();
assertThat(struct3.getStruct().isDefaultRoute()).isFalse();
assertThat(struct4.getStruct().isDefaultRoute()).isFalse();
}
private static io.envoyproxy.envoy.config.route.v3.Route buildSimpleRouteProto(
@Nullable String pathPrefix, @Nullable String path) {
io.envoyproxy.envoy.config.route.v3.Route.Builder routeBuilder =
io.envoyproxy.envoy.config.route.v3.Route.newBuilder()
.setName("simple-route")
.setRoute(io.envoyproxy.envoy.config.route.v3.RouteAction.newBuilder()
.setCluster("simple-cluster"));
if (pathPrefix != null) {
routeBuilder.setMatch(io.envoyproxy.envoy.config.route.v3.RouteMatch.newBuilder()
.setPrefix(pathPrefix));
} else if (path != null) {
routeBuilder.setMatch(io.envoyproxy.envoy.config.route.v3.RouteMatch.newBuilder()
.setPath(path));
}
return routeBuilder.build();
}
@Test
public void convertRouteMatch_pathMatching() {
// path_specifier = prefix
@ -300,7 +267,13 @@ public class EnvoyProtoDataTest {
assertThat(struct1.getErrorDetail()).isNull();
assertThat(struct1.getStruct()).isEqualTo(
new RouteMatch(
new PathMatcher(null, "/", null), Collections.<HeaderMatcher>emptyList(), null));
PathMatcher.fromPrefix("/", false), Collections.<HeaderMatcher>emptyList(), null));
proto1 = proto1.toBuilder().setCaseSensitive(BoolValue.newBuilder().setValue(true)).build();
struct1 = Route.convertEnvoyProtoRouteMatch(proto1);
assertThat(struct1.getStruct()).isEqualTo(
new RouteMatch(
PathMatcher.fromPrefix("/", true), Collections.<HeaderMatcher>emptyList(), null));
// path_specifier = path
io.envoyproxy.envoy.config.route.v3.RouteMatch proto2 =
@ -311,7 +284,14 @@ public class EnvoyProtoDataTest {
assertThat(struct2.getErrorDetail()).isNull();
assertThat(struct2.getStruct()).isEqualTo(
new RouteMatch(
new PathMatcher("/service/method", null, null),
PathMatcher.fromPath("/service/method", false),
Collections.<HeaderMatcher>emptyList(), null));
proto2 = proto2.toBuilder().setCaseSensitive(BoolValue.newBuilder().setValue(true)).build();
struct2 = Route.convertEnvoyProtoRouteMatch(proto2);
assertThat(struct2.getStruct()).isEqualTo(
new RouteMatch(
PathMatcher.fromPath("/service/method", true),
Collections.<HeaderMatcher>emptyList(), null));
// path_specifier = safe_regex
@ -323,18 +303,9 @@ public class EnvoyProtoDataTest {
assertThat(struct4.getErrorDetail()).isNull();
assertThat(struct4.getStruct()).isEqualTo(
new RouteMatch(
new PathMatcher(null, null, Pattern.compile(".")),
PathMatcher.fromRegEx(Pattern.compile(".")),
Collections.<HeaderMatcher>emptyList(), null));
// case_sensitive = false
io.envoyproxy.envoy.config.route.v3.RouteMatch proto5 =
io.envoyproxy.envoy.config.route.v3.RouteMatch.newBuilder()
.setCaseSensitive(BoolValue.newBuilder().setValue(false))
.build();
StructOrError<RouteMatch> struct5 = Route.convertEnvoyProtoRouteMatch(proto5);
assertThat(struct5.getErrorDetail()).isNotNull();
assertThat(struct5.getStruct()).isNull();
// query_parameters is set
io.envoyproxy.envoy.config.route.v3.RouteMatch proto6 =
io.envoyproxy.envoy.config.route.v3.RouteMatch.newBuilder()
@ -370,7 +341,7 @@ public class EnvoyProtoDataTest {
assertThat(struct.getStruct())
.isEqualTo(
new RouteMatch(
new PathMatcher(null, "", null),
PathMatcher.fromPrefix("", false),
Arrays.asList(
new HeaderMatcher(":scheme", null, null, null, null, "http", null, false),
new HeaderMatcher(":method", "PUT", null, null, null, null, null, false)),
@ -394,7 +365,7 @@ public class EnvoyProtoDataTest {
assertThat(struct.getStruct())
.isEqualTo(
new RouteMatch(
new PathMatcher(null, "", null), Collections.<HeaderMatcher>emptyList(),
PathMatcher.fromPrefix( "", false), Collections.<HeaderMatcher>emptyList(),
new FractionMatcher(30, 100)));
}

View File

@ -51,14 +51,14 @@ public class RouteMatchTest {
public void routeMatching_pathOnly() {
RouteMatch routeMatch1 =
new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
PathMatcher.fromPath("/FooService/barMethod", true),
Collections.<HeaderMatcher>emptyList(), null);
assertThat(routeMatch1.matches("/FooService/barMethod", headers)).isTrue();
assertThat(routeMatch1.matches("/FooService/bazMethod", headers)).isFalse();
RouteMatch routeMatch2 =
new RouteMatch(
new PathMatcher(null, "/FooService/", null),
PathMatcher.fromPrefix("/FooService/", true),
Collections.<HeaderMatcher>emptyList(), null);
assertThat(routeMatch2.matches("/FooService/barMethod", headers)).isTrue();
assertThat(routeMatch2.matches("/FooService/bazMethod", headers)).isTrue();
@ -66,15 +66,25 @@ public class RouteMatchTest {
RouteMatch routeMatch3 =
new RouteMatch(
new PathMatcher(null, null, Pattern.compile(".*Foo.*")),
PathMatcher.fromRegEx(Pattern.compile(".*Foo.*")),
Collections.<HeaderMatcher>emptyList(), null);
assertThat(routeMatch3.matches("/FooService/barMethod", headers)).isTrue();
}
@Test
public void pathMatching_caseInsensitive() {
PathMatcher pathMatcher1 = PathMatcher.fromPath("/FooService/barMethod", false);
assertThat(pathMatcher1.matches("/fooservice/barmethod")).isTrue();
PathMatcher pathMatcher2 = PathMatcher.fromPrefix("/FooService", false);
assertThat(pathMatcher2.matches("/fooservice/barmethod")).isTrue();
}
@Test
public void routeMatching_withHeaders() {
PathMatcher pathMatcher = PathMatcher.fromPath("/FooService/barMethod", true);
RouteMatch routeMatch1 = new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
pathMatcher,
Arrays.asList(
new HeaderMatcher(
"grpc-encoding", "gzip", null, null, null, null, null, false),
@ -90,7 +100,7 @@ public class RouteMatchTest {
assertThat(routeMatch1.matches("/FooService/barMethod", headers)).isTrue();
RouteMatch routeMatch2 = new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
pathMatcher,
Collections.singletonList(
new HeaderMatcher(
"authority", null, Pattern.compile(".*googleapis.*"), null, null, null,
@ -99,7 +109,7 @@ public class RouteMatchTest {
assertThat(routeMatch2.matches("/FooService/barMethod", headers)).isFalse();
RouteMatch routeMatch3 = new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
pathMatcher,
Collections.singletonList(
new HeaderMatcher(
"user-agent", "gRPC-Go", null, null, null, null,
@ -108,7 +118,7 @@ public class RouteMatchTest {
assertThat(routeMatch3.matches("/FooService/barMethod", headers)).isFalse();
RouteMatch routeMatch4 = new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
pathMatcher,
Collections.singletonList(
new HeaderMatcher(
"user-agent", null, null, null, false, null,
@ -117,7 +127,7 @@ public class RouteMatchTest {
assertThat(routeMatch4.matches("/FooService/barMethod", headers)).isFalse();
RouteMatch routeMatch5 = new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
pathMatcher,
Collections.singletonList(
new HeaderMatcher(
"user-agent", null, null, null, false, null,
@ -126,7 +136,7 @@ public class RouteMatchTest {
assertThat(routeMatch5.matches("/FooService/barMethod", headers)).isTrue();
RouteMatch routeMatch6 = new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
pathMatcher,
Collections.singletonList(
new HeaderMatcher(
"user-agent", null, null, null, true, null,
@ -135,7 +145,7 @@ public class RouteMatchTest {
assertThat(routeMatch6.matches("/FooService/barMethod", headers)).isFalse();
RouteMatch routeMatch7 = new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
pathMatcher,
Collections.singletonList(
new HeaderMatcher(
"custom-key", "custom-value1,custom-value2", null, null, null, null,
@ -146,16 +156,17 @@ public class RouteMatchTest {
@Test
public void routeMatching_withRuntimeFraction() {
PathMatcher pathMatcher = PathMatcher.fromPath("/FooService/barMethod", true);
RouteMatch routeMatch1 =
new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
pathMatcher,
Collections.<HeaderMatcher>emptyList(),
new FractionMatcher(100, 1000, new FakeRandom(50)));
assertThat(routeMatch1.matches("/FooService/barMethod", headers)).isTrue();
RouteMatch routeMatch2 =
new RouteMatch(
new PathMatcher("/FooService/barMethod", null, null),
pathMatcher,
Collections.<HeaderMatcher>emptyList(),
new FractionMatcher(100, 1000, new FakeRandom(100)));
assertThat(routeMatch2.matches("/FooService/barMethod", headers)).isFalse();
@ -163,11 +174,12 @@ public class RouteMatchTest {
@Test
public void headerMatching_specialCaseGrpcHeaders() {
PathMatcher pathMatcher = PathMatcher.fromPath("/FooService/barMethod", true);
Map<String, Iterable<String>> headers = new HashMap<>();
headers.put("grpc-previous-rpc-attempts", Collections.singletonList("0"));
RouteMatch routeMatch1 =
new RouteMatch(new PathMatcher("/FooService/barMethod", null, null),
new RouteMatch(pathMatcher,
Arrays.asList(
new HeaderMatcher(
"grpc-previous-rpc-attempts", "0", null, null, null, null,
@ -176,7 +188,7 @@ public class RouteMatchTest {
assertThat(routeMatch1.matches("/FooService/barMethod", headers)).isFalse();
RouteMatch routeMatch2 =
new RouteMatch(new PathMatcher("/FooService/barMethod", null, null),
new RouteMatch(pathMatcher,
Arrays.asList(
new HeaderMatcher(
"content-type", "application/grpc", null, null, null, null,

View File

@ -280,9 +280,9 @@ public class XdsNameResolverTest {
}
private List<VirtualHost> buildUnmatchedVirtualHosts() {
Route route1 = new Route(new RouteMatch(null, call2.getFullMethodNameForPath()),
Route route1 = new Route(RouteMatch.withPathExactOnly(call2.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster2, null));
Route route2 = new Route(new RouteMatch(null, call1.getFullMethodNameForPath()),
Route route2 = new Route(RouteMatch.withPathExactOnly(call1.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster1, null));
return Arrays.asList(
new VirtualHost("virtualhost-foo", Collections.singletonList("hello.googleapis.com"),
@ -324,10 +324,10 @@ public class XdsNameResolverTest {
AUTHORITY,
Arrays.asList(
new Route(
new RouteMatch(null, call1.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call1.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(20L), "another-cluster", null)),
new Route(
new RouteMatch(null, call2.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call2.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster2, null))));
verify(mockListener).onResult(resolutionResultCaptor.capture());
ResolutionResult result = resolutionResultCaptor.getValue();
@ -359,10 +359,10 @@ public class XdsNameResolverTest {
AUTHORITY,
Arrays.asList(
new Route(
new RouteMatch(null, call1.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call1.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(20L), "another-cluster", null)),
new Route(
new RouteMatch(null, call2.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call2.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster2, null))));
// Two consecutive service config updates: one for removing clcuster1,
// one for adding "another=cluster".
@ -390,10 +390,10 @@ public class XdsNameResolverTest {
AUTHORITY,
Arrays.asList(
new Route(
new RouteMatch(null, call1.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call1.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(20L), "another-cluster", null)),
new Route(
new RouteMatch(null, call2.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call2.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster2, null))));
verify(mockListener).onResult(resolutionResultCaptor.capture());
@ -406,10 +406,10 @@ public class XdsNameResolverTest {
AUTHORITY,
Arrays.asList(
new Route(
new RouteMatch(null, call1.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call1.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), "another-cluster", null)),
new Route(
new RouteMatch(null, call2.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call2.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster2, null))));
verifyNoMoreInteractions(mockListener); // no cluster added/deleted
assertCallSelectResult(call1, configSelector, "another-cluster", 15.0);
@ -424,16 +424,16 @@ public class XdsNameResolverTest {
AUTHORITY,
Collections.singletonList(
new Route(
new RouteMatch(null, call2.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call2.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster2, null))));
xdsClient.deliverRoutesViaLds(
AUTHORITY,
Arrays.asList(
new Route(
new RouteMatch(null, call1.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call1.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster1, null)),
new Route(
new RouteMatch(null, call2.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call2.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster2, null))));
result.getCommittedCallback().run();
verifyNoMoreInteractions(mockListener);
@ -449,7 +449,7 @@ public class XdsNameResolverTest {
AUTHORITY,
Arrays.asList(
new Route(
new RouteMatch(null, call1.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call1.getFullMethodNameForPath()),
new RouteAction(
TimeUnit.SECONDS.toNanos(20L), null,
Arrays.asList(
@ -505,10 +505,10 @@ public class XdsNameResolverTest {
AUTHORITY,
Arrays.asList(
new Route(
new RouteMatch(null, call1.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call1.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster1, null)),
new Route(
new RouteMatch(null, call2.getFullMethodNameForPath()),
RouteMatch.withPathExactOnly(call2.getFullMethodNameForPath()),
new RouteAction(TimeUnit.SECONDS.toNanos(15L), cluster2, null))));
verify(mockListener).onResult(resolutionResultCaptor.capture());
ResolutionResult result = resolutionResultCaptor.getValue();