grpc-js-xds: Refactor matcher and routeAction for logging, add more interop client logging

This commit is contained in:
Michael Lumish 2021-03-24 14:08:21 -07:00
parent d160a2f248
commit b995fa62cf
4 changed files with 357 additions and 91 deletions

View File

@ -303,6 +303,8 @@ function main() {
currentConfig.callTypes = call.request.types;
currentConfig.metadata = callMetadata;
currentConfig.timeoutSec = call.request.timeout_sec
console.log('Received new client configuration: ' + JSON.stringify(currentConfig, undefined, 2));
callback(null, {});
}
}

View File

@ -0,0 +1,245 @@
/*
* Copyright 2021 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.
*/
import { Metadata } from "@grpc/grpc-js";
import { RE2 } from "re2-wasm";
/**
* An object representing a predicate that determines whether a given
* combination of a methodName and metadata matches some internal conditions.
*/
export interface Matcher {
toString(): string;
apply(methodName: string, metadata: Metadata): boolean;
}
/**
* An object representing a predicate that determines whether a given string
* value matches some internal conditions.
*/
export interface ValueMatcher {
toString(): string;
apply(value: string): boolean;
}
export class ExactValueMatcher implements ValueMatcher {
constructor(private targetValue: string) {}
apply(value: string) {
return value === this.targetValue;
}
toString() {
return 'Exact(' + this.targetValue + ')';
}
}
export class SafeRegexValueMatcher implements ValueMatcher {
private targetRegexImpl: RE2;
constructor(targetRegex: string) {
this.targetRegexImpl = new RE2(`^${targetRegex}$`, 'u');
}
apply(value: string) {
return this.targetRegexImpl.test(value);
}
toString() {
return 'SafeRegex(' + this.targetRegexImpl.toString() + ')';
}
}
const numberRegex = new RE2(/^-?\d+$/u);
export class RangeValueMatcher implements ValueMatcher {
constructor(private start: BigInt, private end: BigInt) {}
apply(value: string) {
if (!numberRegex.test(value)) {
return false;
}
const numberValue = BigInt(value);
return this.start <= numberValue && numberValue < this.end;
}
toString() {
return 'Range(' + this.start + ', ' + this.end + ')';
}
}
export class PresentValueMatcher implements ValueMatcher {
constructor() {}
apply(value: string) {
return true;
}
toString() {
return 'Present()';
}
}
export class PrefixValueMatcher implements ValueMatcher {
constructor(private prefix: string) {}
apply(value: string) {
return value.startsWith(this.prefix);
}
toString() {
return 'Prefix(' + this.prefix + ')';
}
}
export class SuffixValueMatcher implements ValueMatcher {
constructor(private suffix: string) {}
apply(value: string) {
return value.endsWith(this.suffix);
}
toString() {
return 'Suffix(' + this.suffix + ')';
}
}
export class RejectValueMatcher implements ValueMatcher {
constructor() {}
apply(value: string) {
return false;
}
toString() {
return 'Reject()';
}
}
export class HeaderMatcher implements Matcher {
constructor(private headerName: string, private valueMatcher: ValueMatcher, private invertMatch: boolean) {}
private applyHelper(methodName: string, metadata: Metadata) {
if (this.headerName.endsWith('-bin')) {
return false;
}
let value: string;
if (this.headerName === 'content-type') {
value = 'application/grpc';
} else {
const valueArray = metadata.get(this.headerName);
if (valueArray.length === 0) {
return false;
} else {
value = valueArray.join(',');
}
}
return this.valueMatcher.apply(value);
}
apply(methodName: string, metadata: Metadata) {
const result = this.applyHelper(methodName, metadata);
if (this.invertMatch) {
return !result;
} else {
return result;
}
}
toString() {
return 'HeaderMatch(' + this.headerName + ', ' + this.valueMatcher.toString() + ')';
}
}
export class PathPrefixValueMatcher {
constructor(private prefix: string, private caseInsensitive: boolean) {}
apply(value: string) {
if (this.caseInsensitive) {
return value.toLowerCase().startsWith(this.prefix.toLowerCase());
} else {
return value.startsWith(this.prefix);
}
}
toString() {
return 'Prefix(' + this.prefix + ', ' + this.caseInsensitive + ')';
}
}
export class PathExactValueMatcher {
constructor(private targetValue: string, private caseInsensitive: boolean) {}
apply(value: string) {
if (this.caseInsensitive) {
return value.toLowerCase().startsWith(this.targetValue.toLowerCase());
} else {
return value === this.targetValue;
}
}
toString() {
return 'Exact(' + this.targetValue + ', ' + this.caseInsensitive + ')';
}
}
export class PathSafeRegexValueMatcher {
private targetRegexImpl: RE2;
constructor(targetRegex: string, caseInsensitive: boolean) {
this.targetRegexImpl = new RE2(`^${targetRegex}$`, caseInsensitive ? 'iu' : 'u');
}
apply(value: string) {
return this.targetRegexImpl.test(value);
}
toString() {
return 'SafeRegex(' + this.targetRegexImpl.toString() + ')';
}
}
export interface Fraction {
numerator: number;
denominator: number;
}
function fractionToString(fraction: Fraction): string {
return `${fraction.numerator}/${fraction.denominator}`;
}
export class FullMatcher implements Matcher {
constructor(private pathMatcher: ValueMatcher, private headerMatchers: Matcher[], private fraction: Fraction | null) {}
apply(methodName: string, metadata: Metadata) {
if (!this.pathMatcher.apply(methodName)) {
return false;
}
if (!this.headerMatchers.every(matcher => matcher.apply(methodName, metadata))) {
return false;
}
if (this.fraction === null) {
return true;
} else {
const randomNumber = Math.random() * this.fraction.denominator;
return randomNumber < this.fraction.numerator;
}
}
toString() {
return `path: ${this.pathMatcher}
headers: ${this.headerMatchers.map(matcher => matcher.toString()).join('\n\t')}
fraction: ${this.fraction ? fractionToString(this.fraction): 'none'}`;
}
}

View File

@ -38,6 +38,8 @@ import { HeaderMatcher__Output } from './generated/envoy/api/v2/route/HeaderMatc
import ConfigSelector = experimental.ConfigSelector;
import LoadBalancingConfig = experimental.LoadBalancingConfig;
import { XdsClusterManagerLoadBalancingConfig } from './load-balancer-xds-cluster-manager';
import { ExactValueMatcher, Fraction, FullMatcher, HeaderMatcher, Matcher, PathExactValueMatcher, PathPrefixValueMatcher, PathSafeRegexValueMatcher, PrefixValueMatcher, PresentValueMatcher, RangeValueMatcher, RejectValueMatcher, SafeRegexValueMatcher, SuffixValueMatcher, ValueMatcher } from './matcher';
import { RouteAction, SingleClusterRouteAction, WeightedCluster, WeightedClusterRouteAction } from './route-action';
const TRACER_NAME = 'xds_resolver';
@ -119,68 +121,35 @@ function findVirtualHostForDomain(virutalHostList: VirtualHost__Output[], domain
return targetVhost;
}
interface Matcher {
(methodName: string, metadata: Metadata): boolean;
}
const numberRegex = new RE2(/^-?\d+$/u);
function getPredicateForHeaderMatcher(headerMatch: HeaderMatcher__Output): Matcher {
let valueChecker: (value: string) => boolean;
let valueChecker: ValueMatcher;
switch (headerMatch.header_match_specifier) {
case 'exact_match':
valueChecker = value => value === headerMatch.exact_match;
valueChecker = new ExactValueMatcher(headerMatch.exact_match!);
break;
case 'safe_regex_match':
const regex = new RE2(`^${headerMatch.safe_regex_match}$`, 'u');
valueChecker = value => regex.test(value);
valueChecker = new SafeRegexValueMatcher(headerMatch.safe_regex_match!.regex);
break;
case 'range_match':
const start = BigInt(headerMatch.range_match!.start);
const end = BigInt(headerMatch.range_match!.end);
valueChecker = value => {
if (!numberRegex.test(value)) {
return false;
}
const numberValue = BigInt(value);
return start <= numberValue && numberValue < end;
}
valueChecker = new RangeValueMatcher(start, end);
break;
case 'present_match':
valueChecker = value => true;
valueChecker = new PresentValueMatcher();
break;
case 'prefix_match':
valueChecker = value => value.startsWith(headerMatch.prefix_match!);
valueChecker = new PrefixValueMatcher(headerMatch.prefix_match!);
break;
case 'suffix_match':
valueChecker = value => value.endsWith(headerMatch.suffix_match!);
valueChecker = new SuffixValueMatcher(headerMatch.suffix_match!);
break;
default:
// Should be prevented by validation rules
return (methodName, metadata) => false;
}
const headerMatcher: Matcher = (methodName, metadata) => {
if (headerMatch.name.endsWith('-bin')) {
return false;
}
let value: string;
if (headerMatch.name === 'content-type') {
value = 'application/grpc';
} else {
const valueArray = metadata.get(headerMatch.name);
if (valueArray.length === 0) {
return false;
} else {
value = valueArray.join(',');
}
}
return valueChecker(value);
}
if (headerMatch.invert_match) {
return (methodName, metadata) => !headerMatcher(methodName, metadata);
} else {
return headerMatcher;
valueChecker = new RejectValueMatcher();
}
return new HeaderMatcher(headerMatch.name, valueChecker, headerMatch.invert_match);
}
const RUNTIME_FRACTION_DENOMINATOR_VALUES = {
@ -190,48 +159,32 @@ const RUNTIME_FRACTION_DENOMINATOR_VALUES = {
}
function getPredicateForMatcher(routeMatch: RouteMatch__Output): Matcher {
let pathMatcher: Matcher;
let pathMatcher: ValueMatcher;
const caseInsensitive = routeMatch.case_sensitive?.value === false;
switch (routeMatch.path_specifier) {
case 'prefix':
if (routeMatch.case_sensitive?.value === false) {
const prefix = routeMatch.prefix!.toLowerCase();
pathMatcher = (methodName, metadata) => (methodName.toLowerCase().startsWith(prefix));
} else {
const prefix = routeMatch.prefix!;
pathMatcher = (methodName, metadata) => (methodName.startsWith(prefix));
}
pathMatcher = new PathPrefixValueMatcher(routeMatch.prefix!, caseInsensitive);
break;
case 'path':
if (routeMatch.case_sensitive?.value === false) {
const path = routeMatch.path!.toLowerCase();
pathMatcher = (methodName, metadata) => (methodName.toLowerCase() === path);
} else {
const path = routeMatch.path!;
pathMatcher = (methodName, metadata) => (methodName === path);
}
pathMatcher = new PathExactValueMatcher(routeMatch.path!, caseInsensitive);
break;
case 'safe_regex':
const flags = routeMatch.case_sensitive?.value === false ? 'ui' : 'u';
const regex = new RE2(`^${routeMatch.safe_regex!.regex!}$`, flags);
pathMatcher = (methodName, metadata) => (regex.test(methodName));
pathMatcher = new PathSafeRegexValueMatcher(routeMatch.safe_regex!.regex, caseInsensitive);
break;
default:
// Should be prevented by validation rules
return (methodName, metadata) => false;
pathMatcher = new RejectValueMatcher();
}
const headerMatchers: Matcher[] = routeMatch.headers.map(getPredicateForHeaderMatcher);
let runtimeFractionHandler: () => boolean;
let runtimeFraction: Fraction | null;
if (!routeMatch.runtime_fraction?.default_value) {
runtimeFractionHandler = () => true;
runtimeFraction = null;
} else {
const numerator = routeMatch.runtime_fraction.default_value.numerator;
const denominator = RUNTIME_FRACTION_DENOMINATOR_VALUES[routeMatch.runtime_fraction.default_value.denominator];
runtimeFractionHandler = () => {
const randomNumber = Math.random() * denominator;
return randomNumber < numerator;
}
runtimeFraction = {
numerator: routeMatch.runtime_fraction.default_value.numerator,
denominator: RUNTIME_FRACTION_DENOMINATOR_VALUES[routeMatch.runtime_fraction.default_value.denominator]
};
}
return (methodName, metadata) => pathMatcher(methodName, metadata) && headerMatchers.every(matcher => matcher(methodName, metadata)) && runtimeFractionHandler();
return new FullMatcher(pathMatcher, headerMatchers, runtimeFraction);
}
class XdsResolver implements Resolver {
@ -340,38 +293,27 @@ class XdsResolver implements Resolver {
this.reportResolutionError('No matching route found');
return;
}
trace('Received virtual host config ' + JSON.stringify(virtualHost, undefined, 2));
const allConfigClusters = new Set<string>();
const matchList: {matcher: Matcher, action: () => string}[] = [];
const matchList: {matcher: Matcher, action: RouteAction}[] = [];
for (const route of virtualHost.routes) {
let routeAction: () => string;
let routeAction: RouteAction;
switch (route.route!.cluster_specifier) {
case 'cluster_header':
continue;
case 'cluster':{
const cluster = route.route!.cluster!;
allConfigClusters.add(cluster);
routeAction = () => cluster;
routeAction = new SingleClusterRouteAction(cluster);
break;
}
case 'weighted_clusters': {
let lastNumerator = 0;
// clusterChoices is essentially the weighted choices represented as a CDF
const clusterChoices: {cluster: string, numerator: number}[] = [];
const weightedClusters: WeightedCluster[] = [];
for (const clusterWeight of route.route!.weighted_clusters!.clusters) {
allConfigClusters.add(clusterWeight.name);
lastNumerator = lastNumerator + (clusterWeight.weight?.value ?? 0);
clusterChoices.push({cluster: clusterWeight.name, numerator: lastNumerator});
}
routeAction = () => {
const randomNumber = Math.random() * (route.route!.weighted_clusters!.total_weight?.value ?? 100);
for (const choice of clusterChoices) {
if (randomNumber < choice.numerator) {
return choice.cluster;
}
}
// This should be prevented by the validation rules
return '';
weightedClusters.push({name: clusterWeight.name, weight: clusterWeight.weight?.value ?? 0});
}
routeAction = new WeightedClusterRouteAction(weightedClusters, route.route!.weighted_clusters!.total_weight?.value ?? 100);
}
}
const routeMatcher = getPredicateForMatcher(route.match!);
@ -397,8 +339,8 @@ class XdsResolver implements Resolver {
}
const configSelector: ConfigSelector = (methodName, metadata) => {
for (const {matcher, action} of matchList) {
if (matcher(methodName, metadata)) {
const clusterName = action();
if (matcher.apply(methodName, metadata)) {
const clusterName = action.getCluster();
this.refCluster(clusterName);
const onCommitted = () => {
this.unrefCluster(clusterName);
@ -418,6 +360,11 @@ class XdsResolver implements Resolver {
status: status.UNAVAILABLE
};
};
trace('Created ConfigSelector with configuration:');
for (const {matcher, action} of matchList) {
trace(matcher.toString());
trace('=> ' + action.toString());
}
const clusterConfigMap = new Map<string, {child_policy: LoadBalancingConfig[]}>();
for (const clusterName of this.clusterRefcounts.keys()) {
clusterConfigMap.set(clusterName, {child_policy: [new CdsLoadBalancingConfig(clusterName)]});

View File

@ -0,0 +1,72 @@
/*
* Copyright 2021 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.
*/
export interface RouteAction {
toString(): string;
getCluster(): string;
}
export class SingleClusterRouteAction implements RouteAction {
constructor(private cluster: string) {}
getCluster() {
return this.cluster;
}
toString() {
return 'SingleCluster(' + this.cluster + ')';
}
}
export interface WeightedCluster {
name: string;
weight: number;
}
interface ClusterChoice {
name: string;
numerator: number;
}
export class WeightedClusterRouteAction implements RouteAction {
/**
* The weighted cluster choices represented as a CDF
*/
private clusterChoices: ClusterChoice[];
constructor(private clusters: WeightedCluster[], private totalWeight: number) {
this.clusterChoices = [];
let lastNumerator = 0;
for (const clusterWeight of clusters) {
lastNumerator += clusterWeight.weight;
this.clusterChoices.push({name: clusterWeight.name, numerator: lastNumerator});
}
}
getCluster() {
const randomNumber = Math.random() * this.totalWeight;
for (const choice of this.clusterChoices) {
if (randomNumber < choice.numerator) {
return choice.name;
}
}
// This should be prevented by the validation rules
return '';
}
toString() {
return 'WeightedCluster(' + this.clusters.map(({name, weight}) => '(' + name + ':' + weight + ')').join(', ') + ')';
}
}