diff --git a/packages/grpc-js-xds/interop/xds-interop-client.ts b/packages/grpc-js-xds/interop/xds-interop-client.ts index c3cb55d5..f70ed4ff 100644 --- a/packages/grpc-js-xds/interop/xds-interop-client.ts +++ b/packages/grpc-js-xds/interop/xds-interop-client.ts @@ -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, {}); } } diff --git a/packages/grpc-js-xds/src/matcher.ts b/packages/grpc-js-xds/src/matcher.ts new file mode 100644 index 00000000..a70cf5d6 --- /dev/null +++ b/packages/grpc-js-xds/src/matcher.ts @@ -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'}`; + } +} \ No newline at end of file diff --git a/packages/grpc-js-xds/src/resolver-xds.ts b/packages/grpc-js-xds/src/resolver-xds.ts index 99bbd58c..0fbb3030 100644 --- a/packages/grpc-js-xds/src/resolver-xds.ts +++ b/packages/grpc-js-xds/src/resolver-xds.ts @@ -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(); - 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(); for (const clusterName of this.clusterRefcounts.keys()) { clusterConfigMap.set(clusterName, {child_policy: [new CdsLoadBalancingConfig(clusterName)]}); diff --git a/packages/grpc-js-xds/src/route-action.ts b/packages/grpc-js-xds/src/route-action.ts new file mode 100644 index 00000000..4ba2b590 --- /dev/null +++ b/packages/grpc-js-xds/src/route-action.ts @@ -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(', ') + ')'; + } +} \ No newline at end of file