mirror of https://github.com/grpc/grpc-node.git
Add resolver and service config handling code
This commit is contained in:
parent
a996adaade
commit
acdd2abfc3
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright 2019 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.
|
||||
*
|
||||
*/
|
||||
|
||||
/* This file is an implementation of gRFC A24:
|
||||
* https://github.com/grpc/proposal/blob/master/A24-lb-policy-config.md */
|
||||
|
||||
import { isString, isArray } from "util";
|
||||
|
||||
export interface RoundRobinConfig {
|
||||
}
|
||||
|
||||
export interface XdsConfig {
|
||||
balancerName: string;
|
||||
childPolicy: LoadBalancingConfig[];
|
||||
fallbackPolicy: LoadBalancingConfig[];
|
||||
}
|
||||
|
||||
export interface GrpcLbConfig {
|
||||
childPolicy: LoadBalancingConfig[];
|
||||
}
|
||||
|
||||
export interface LoadBalancingConfig {
|
||||
/* Exactly one of these must be set for a config to be valid */
|
||||
round_robin?: RoundRobinConfig;
|
||||
xds?: XdsConfig;
|
||||
grpclb?: GrpcLbConfig;
|
||||
}
|
||||
|
||||
/* In these functions we assume the input came from a JSON object. Therefore we
|
||||
* expect that the prototype is uninteresting and that `in` can be used
|
||||
* effectively */
|
||||
|
||||
function validateXdsConfig(xds: any): XdsConfig {
|
||||
if (!('balancerName' in xds) || !isString(xds.balancerName)) {
|
||||
throw new Error('Invalid xds config: invalid balancerName');
|
||||
}
|
||||
const xdsConfig: XdsConfig = {
|
||||
balancerName: xds.balancerName,
|
||||
childPolicy: [],
|
||||
fallbackPolicy: []
|
||||
};
|
||||
if ('childPolicy' in xds) {
|
||||
if (!isArray(xds.childPolicy)) {
|
||||
throw new Error('Invalid xds config: invalid childPolicy');
|
||||
}
|
||||
for (const policy of xds.childPolicy) {
|
||||
xdsConfig.childPolicy.push(validateConfig(policy));
|
||||
}
|
||||
}
|
||||
if ('fallbackPolicy' in xds) {
|
||||
if (!isArray(xds.fallbackPolicy)) {
|
||||
throw new Error('Invalid xds config: invalid fallbackPolicy');
|
||||
}
|
||||
for (const policy of xds.fallbackPolicy) {
|
||||
xdsConfig.fallbackPolicy.push(validateConfig(policy));
|
||||
}
|
||||
}
|
||||
return xdsConfig;
|
||||
}
|
||||
|
||||
function validateGrpcLbConfig(grpclb: any): GrpcLbConfig {
|
||||
const grpcLbConfig: GrpcLbConfig = {
|
||||
childPolicy: []
|
||||
};
|
||||
if ('childPolicy' in grpclb) {
|
||||
if (!isArray(grpclb.childPolicy)) {
|
||||
throw new Error('Invalid xds config: invalid childPolicy');
|
||||
}
|
||||
for (const policy of grpclb.childPolicy) {
|
||||
grpcLbConfig.childPolicy.push(validateConfig(policy));
|
||||
}
|
||||
}
|
||||
return grpcLbConfig;
|
||||
}
|
||||
|
||||
export function validateConfig(obj: any): LoadBalancingConfig {
|
||||
if ('round_robin' in obj) {
|
||||
if ('xds' in obj || 'grpclb' in obj) {
|
||||
throw new Error('Multiple load balancing policies configured');
|
||||
}
|
||||
if (obj['round_robin'] instanceof Object) {
|
||||
return { round_robin: {} }
|
||||
}
|
||||
}
|
||||
if ('xds' in obj) {
|
||||
if ('grpclb' in obj) {
|
||||
throw new Error('Multiple load balancing policies configured');
|
||||
}
|
||||
return {xds: validateXdsConfig(obj.xds)};
|
||||
}
|
||||
if ('grpclb' in obj) {
|
||||
return {grpclb: validateGrpcLbConfig(obj.grpclb)};
|
||||
}
|
||||
throw new Error('No recognized load balancing policy configured');
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright 2019 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 { Resolver, ResolverListener, registerResolver, registerDefaultResolver } from './resolver';
|
||||
import * as dns from 'dns';
|
||||
import * as util from 'util';
|
||||
import { extractAndSelectServiceConfig, ServiceConfig } from './service-config';
|
||||
|
||||
/* These regular expressions match IP addresses with optional ports in different
|
||||
* formats. In each case, capture group 1 contains the address, and capture
|
||||
* group 2 contains the port number, if present */
|
||||
const IPv4_REGEX = /^(\d{1,3}(?:\.\d{1,3}){3})(?::(\d+))?$/;
|
||||
const IPv6_REGEX = /^([0-9a-f]{0,4}(?::{1,2}[0-9a-f]{0,4})+)$/i;
|
||||
const IPv6_BRACKET_REGEX = /^\[([0-9a-f]{0,4}(?::{1,2}[0-9a-f]{0,4})+)\](?::(\d+))?$/i;
|
||||
|
||||
const DNS_REGEX = /^(?:dns:)?(?:\/\/\w+\/)?(\w+)(?::(\d+))?$/;
|
||||
|
||||
const DEFAULT_PORT = '443';
|
||||
|
||||
const resolve4Promise = util.promisify(dns.resolve4);
|
||||
const resolve6Promise = util.promisify(dns.resolve6);
|
||||
|
||||
function parseIP(target: string): string | null {
|
||||
/* These three regular expressions are all mutually exclusive, so we just
|
||||
* want the first one that matches the target string, if any do. */
|
||||
const match = IPv4_REGEX.exec(target) || IPv6_REGEX.exec(target) || IPv6_BRACKET_REGEX.exec(target);
|
||||
if (match === null) {
|
||||
return null;
|
||||
}
|
||||
const addr = match[1];
|
||||
let port: string;
|
||||
if (match[2]) {
|
||||
port = match[2];
|
||||
} else {
|
||||
port = DEFAULT_PORT;
|
||||
}
|
||||
return `${addr}:${port}`;
|
||||
}
|
||||
|
||||
function mergeArrays<T>(...arrays: T[][]): T[] {
|
||||
const result: T[] = [];
|
||||
for(let i = 0; i<Math.max.apply(null, arrays.map((array)=> array.length)); i++) {
|
||||
for(let array of arrays) {
|
||||
if(i < array.length) {
|
||||
result.push(array[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class DnsResolver implements Resolver {
|
||||
ipResult: string | null;
|
||||
dnsHostname: string | null;
|
||||
port: string | null;
|
||||
/* The promise results here contain, in order, the A record, the AAAA record,
|
||||
* and either the TXT record or an error if TXT resolution failed */
|
||||
pendingResultPromise: Promise<[string[], string[], string[][] | Error]> | null = null;
|
||||
percentage: number;
|
||||
constructor(private target: string, private listener: ResolverListener) {
|
||||
this.ipResult = parseIP(target);
|
||||
const dnsMatch = DNS_REGEX.exec(target);
|
||||
if (dnsMatch === null) {
|
||||
this.dnsHostname = null;
|
||||
this.port = null;
|
||||
} else {
|
||||
this.dnsHostname = dnsMatch[1];
|
||||
if (dnsMatch[2]) {
|
||||
this.port = dnsMatch[2];
|
||||
} else {
|
||||
this.port = DEFAULT_PORT;
|
||||
}
|
||||
}
|
||||
this.percentage = Math.random() * 100;
|
||||
this.startResolution();
|
||||
}
|
||||
|
||||
private startResolution() {
|
||||
if (this.ipResult !== null) {
|
||||
setImmediate(() => {
|
||||
this.listener.onSuccessfulResolution([this.ipResult!], null, null);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.dnsHostname !== null) {
|
||||
const hostname: string = this.dnsHostname;
|
||||
const Aresult = resolve4Promise(hostname);
|
||||
const AAAAresult = resolve6Promise(hostname);
|
||||
const TXTresult = new Promise<string[][] | Error>((resolve, reject) => {
|
||||
dns.resolveTxt(hostname, (err, records) => {
|
||||
if (err) {
|
||||
resolve(err);
|
||||
} else {
|
||||
resolve(records);
|
||||
}
|
||||
});
|
||||
});
|
||||
this.pendingResultPromise = Promise.all([Aresult, AAAAresult, TXTresult]);
|
||||
this.pendingResultPromise.then(([Arecord, AAAArecord, TXTrecord]) => {
|
||||
this.pendingResultPromise = null;
|
||||
const allAddresses: string[] = mergeArrays(AAAArecord, Arecord);
|
||||
let serviceConfig: ServiceConfig | null = null;
|
||||
let serviceConfigError: Error | null = null;
|
||||
if (TXTrecord instanceof Error) {
|
||||
serviceConfigError = TXTrecord;
|
||||
} else {
|
||||
try {
|
||||
serviceConfig = extractAndSelectServiceConfig(TXTrecord, this.percentage);
|
||||
} catch (err) {
|
||||
serviceConfigError = err;
|
||||
}
|
||||
}
|
||||
this.listener.onSuccessfulResolution(allAddresses, serviceConfig, serviceConfigError);
|
||||
}, (err) => {
|
||||
this.pendingResultPromise = null;
|
||||
this.listener.onError(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateResolution() {
|
||||
if (this.pendingResultPromise === null) {
|
||||
this.startResolution();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setup(): void {
|
||||
registerResolver('dns:', DnsResolver);
|
||||
registerDefaultResolver(DnsResolver);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright 2019 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 { ServiceError } from "./call";
|
||||
import { ServiceConfig } from "./service-config";
|
||||
|
||||
export interface ResolverListener {
|
||||
onSuccessfulResolution(addressList: string[], serviceConfig: ServiceConfig | null, serviceConfigError: Error | null): void;
|
||||
onError(error: ServiceError): void;
|
||||
}
|
||||
|
||||
export interface Resolver {
|
||||
updateResolution(): void;
|
||||
}
|
||||
|
||||
export interface ResolverConstructor {
|
||||
new(target: string, listener: ResolverListener): Resolver;
|
||||
}
|
||||
|
||||
const registeredResolvers: {[prefix: string]: ResolverConstructor} = {};
|
||||
let defaultResolver: ResolverConstructor | null = null;
|
||||
|
||||
export function registerResolver(prefix: string, resolverClass: ResolverConstructor) {
|
||||
registeredResolvers[prefix] = resolverClass;
|
||||
}
|
||||
|
||||
export function registerDefaultResolver(resolverClass: ResolverConstructor) {
|
||||
defaultResolver = resolverClass;
|
||||
}
|
||||
|
||||
export function createResolver(target: string, listener: ResolverListener): Resolver {
|
||||
for (const prefix of Object.keys(registeredResolvers)) {
|
||||
if (target.startsWith(prefix)) {
|
||||
return new registeredResolvers[prefix](target, listener);
|
||||
}
|
||||
}
|
||||
if (defaultResolver !== null) {
|
||||
return new defaultResolver(target, listener);
|
||||
}
|
||||
throw new Error('No resolver could be created for the provided target');
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
/*
|
||||
* Copyright 2019 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.
|
||||
*
|
||||
*/
|
||||
|
||||
/* This file implements gRFC A2 and the service config spec:
|
||||
* https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md
|
||||
* https://github.com/grpc/grpc/blob/master/doc/service_config.md */
|
||||
|
||||
import * as lbconfig from './load-balancing-config';
|
||||
import { isString, isArray, isBoolean, isNumber } from 'util';
|
||||
import * as os from 'os';
|
||||
|
||||
export interface MethodConfigName {
|
||||
service: string;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
export interface MethodConfig {
|
||||
name: MethodConfigName[];
|
||||
waitForReady?: boolean;
|
||||
timeout?: string;
|
||||
maxRequestBytes?: number;
|
||||
maxResponseBytes?: number;
|
||||
}
|
||||
|
||||
export interface ServiceConfig {
|
||||
loadBalancingPolicy?: string;
|
||||
loadBalancingConfig: lbconfig.LoadBalancingConfig[]
|
||||
methodConfig: MethodConfig[];
|
||||
}
|
||||
|
||||
export interface ServiceConfigCanaryConfig {
|
||||
clientLanguage?: string[];
|
||||
percentage?: number;
|
||||
clientHostname?: string[];
|
||||
serviceConfig: ServiceConfig;
|
||||
}
|
||||
|
||||
const TIMEOUT_REGEX = /^\d+(\.\d{1,9})?s$/;
|
||||
|
||||
const CLIENT_LANGUAGE_STRING = 'node';
|
||||
|
||||
function validateName(obj: any): MethodConfigName {
|
||||
if (!('service' in obj) || !isString(obj.service)) {
|
||||
throw new Error('Invalid method config name: invalid service');
|
||||
}
|
||||
const result: MethodConfigName = {
|
||||
service: obj.service
|
||||
};
|
||||
if ('method' in obj) {
|
||||
if (isString(obj.method)) {
|
||||
result.method = obj.method;
|
||||
} else {
|
||||
throw new Error('Invalid method config name: invalid method');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateMethodConfig(obj: any): MethodConfig {
|
||||
const result: MethodConfig = {
|
||||
name: []
|
||||
};
|
||||
if (!('name' in obj) || !isArray(obj.name)) {
|
||||
throw new Error('Invalid method config: invalid name array');
|
||||
}
|
||||
for (const name of obj.name) {
|
||||
result.name.push(validateName(name));
|
||||
}
|
||||
if ('waitForReady' in obj) {
|
||||
if (!isBoolean(obj.waitForReady)) {
|
||||
throw new Error('Invalid method config: invalid waitForReady');
|
||||
}
|
||||
result.waitForReady = obj.waitForReady;
|
||||
}
|
||||
if ('timeout' in obj) {
|
||||
if (!isString(obj.timeout) || !TIMEOUT_REGEX.test(obj.timeout)) {
|
||||
throw new Error('Invalid method config: invalid timeout');
|
||||
}
|
||||
result.timeout = obj.timeout;
|
||||
}
|
||||
if ('maxRequestBytes' in obj) {
|
||||
if (!isNumber(obj.maxRequestBytes)) {
|
||||
throw new Error('Invalid method config: invalid maxRequestBytes');
|
||||
}
|
||||
result.maxRequestBytes = obj.maxRequestBytes;
|
||||
}
|
||||
if ('maxResponseBytes' in obj) {
|
||||
if (!isNumber(obj.maxResponseBytes)) {
|
||||
throw new Error('Invalid method config: invalid maxRequestBytes');
|
||||
}
|
||||
result.maxResponseBytes = obj.maxResponseBytes;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateServiceConfig(obj: any): ServiceConfig {
|
||||
const result: ServiceConfig = {
|
||||
loadBalancingConfig: [],
|
||||
methodConfig: []
|
||||
};
|
||||
if ('loadBalancingPolicy' in obj) {
|
||||
if (isString(obj.loadBalancingPolicy)) {
|
||||
result.loadBalancingPolicy = obj.loadBalancingPolicy;
|
||||
} else {
|
||||
throw new Error('Invalid service config: invalid loadBalancingPolicy');
|
||||
}
|
||||
}
|
||||
if ('loadBalancingConfig' in obj) {
|
||||
if (isArray(obj.loadBalancingConfig)) {
|
||||
for (const config of obj.loadBalancingConfig) {
|
||||
result.loadBalancingConfig.push(lbconfig.validateConfig(config));
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid service config: invalid loadBalancingConfig');
|
||||
}
|
||||
}
|
||||
if ('methodConfig' in obj) {
|
||||
if (isArray(obj.methodConfig)) {
|
||||
for (const methodConfig of obj.methodConfig) {
|
||||
result.methodConfig.push(validateMethodConfig(methodConfig));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate method name uniqueness
|
||||
const seenMethodNames: MethodConfigName[] = [];
|
||||
for (const methodConfig of result.methodConfig) {
|
||||
for (const name of methodConfig.name) {
|
||||
for (const seenName of seenMethodNames) {
|
||||
if (name.service === seenName.service && name.method === seenName.method) {
|
||||
throw new Error(`Invalid service config: duplicate name ${name.service}/${name.method}`);
|
||||
}
|
||||
}
|
||||
seenMethodNames.push(name);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateCanaryConfig(obj: any): ServiceConfigCanaryConfig {
|
||||
if (!('serviceConfig' in obj)) {
|
||||
throw new Error('Invalid service config choice: missing service config');
|
||||
}
|
||||
const result: ServiceConfigCanaryConfig = {
|
||||
serviceConfig: validateServiceConfig(obj.serviceConfig)
|
||||
}
|
||||
if ('clientLanguage' in obj) {
|
||||
if (isArray(obj.clientLanguage)) {
|
||||
result.clientLanguage = [];
|
||||
for (const lang of obj.clientLanguage) {
|
||||
if (isString(lang)) {
|
||||
result.clientLanguage.push(lang);
|
||||
} else {
|
||||
throw new Error('Invalid service config choice: invalid clientLanguage');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid service config choice: invalid clientLanguage');
|
||||
}
|
||||
}
|
||||
if ('clientHostname' in obj) {
|
||||
if (isArray(obj.clientHostname)) {
|
||||
result.clientHostname = [];
|
||||
for (const lang of obj.clientHostname) {
|
||||
if (isString(lang)) {
|
||||
result.clientHostname.push(lang);
|
||||
} else {
|
||||
throw new Error('Invalid service config choice: invalid clientHostname');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid service config choice: invalid clientHostname');
|
||||
}
|
||||
}
|
||||
if ('percentage' in obj) {
|
||||
if (isNumber(obj.percentage) && 0 <= obj.percentage && obj.percentage <= 100) {
|
||||
result.percentage = obj.percentage;
|
||||
} else {
|
||||
throw new Error('Invalid service config choice: invalid percentage');
|
||||
}
|
||||
}
|
||||
// Validate that no unexpected fields are present
|
||||
const allowedFields = ['clientLanguage', 'percentage', 'clientHostname', 'serviceConfig'];
|
||||
for (const field in obj) {
|
||||
if (!allowedFields.includes(field)) {
|
||||
throw new Error(`Invalid service config choice: unexpected field ${field}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateAndSelectCanaryConfig(obj: any, percentage: number): ServiceConfig {
|
||||
if (!isArray(obj)) {
|
||||
throw new Error('Invalid service config list');
|
||||
}
|
||||
for (const config of obj) {
|
||||
const validatedConfig = validateCanaryConfig(config);
|
||||
/* For each field, we check if it is present, then only discard the
|
||||
* config if the field value does not match the current client */
|
||||
if (isNumber(validatedConfig.percentage) && percentage > validatedConfig.percentage) {
|
||||
continue;
|
||||
}
|
||||
if (isArray(validatedConfig.clientHostname)) {
|
||||
let hostnameMatched = false;
|
||||
for (const hostname of validatedConfig.clientHostname) {
|
||||
if (hostname === os.hostname()) {
|
||||
hostnameMatched = true;
|
||||
}
|
||||
}
|
||||
if (!hostnameMatched) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (isArray(validatedConfig.clientLanguage)) {
|
||||
let languageMatched = false;
|
||||
for (const language of validatedConfig.clientLanguage) {
|
||||
if (language === CLIENT_LANGUAGE_STRING) {
|
||||
languageMatched = true;
|
||||
}
|
||||
}
|
||||
if (!languageMatched) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return validatedConfig.serviceConfig;
|
||||
}
|
||||
throw new Error('No matching service config found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the "grpc_config" record among the TXT records, parse its value as JSON, validate its contents,
|
||||
* and select a service config with selection fields that all match this client. Most of these steps
|
||||
* can fail with an error; the caller must handle any errors thrown this way.
|
||||
* @param txtRecord The TXT record array that is output from a successful call to dns.resolveTxt
|
||||
* @param percentage A number chosen from the range [0, 100) that is used to select which config to use
|
||||
*/
|
||||
export function extractAndSelectServiceConfig(txtRecord: string[][], percentage: number): ServiceConfig | null {
|
||||
for (const record of txtRecord) {
|
||||
if (record.length > 0 && record[0].startsWith('grpc_config=')) {
|
||||
const recordString = [record[0].substring('grpc_config='.length)].concat(record.slice(1)).join('');
|
||||
const recordJson: any = JSON.parse(recordString);
|
||||
return validateAndSelectCanaryConfig(recordJson, percentage);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Loading…
Reference in New Issue