UNPKG

10.5 kBPlain TextView Raw
1/*
2 * Copyright 2019 gRPC authors.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 *
16 */
17
18/* This file implements gRFC A2 and the service config spec:
19 * https://github.com/grpc/proposal/blob/master/A2-service-configs-in-dns.md
20 * https://github.com/grpc/grpc/blob/master/doc/service_config.md. Each
21 * function here takes an object with unknown structure and returns its
22 * specific object type if the input has the right structure, and throws an
23 * error otherwise. */
24
25/* The any type is purposely used here. All functions validate their input at
26 * runtime */
27/* eslint-disable @typescript-eslint/no-explicit-any */
28
29import * as os from 'os';
30import { Duration } from './duration';
31import {
32 LoadBalancingConfig,
33 validateLoadBalancingConfig,
34} from './load-balancer';
35
36export interface MethodConfigName {
37 service: string;
38 method?: string;
39}
40
41export interface MethodConfig {
42 name: MethodConfigName[];
43 waitForReady?: boolean;
44 timeout?: Duration;
45 maxRequestBytes?: number;
46 maxResponseBytes?: number;
47}
48
49export interface ServiceConfig {
50 loadBalancingPolicy?: string;
51 loadBalancingConfig: LoadBalancingConfig[];
52 methodConfig: MethodConfig[];
53}
54
55export interface ServiceConfigCanaryConfig {
56 clientLanguage?: string[];
57 percentage?: number;
58 clientHostname?: string[];
59 serviceConfig: ServiceConfig;
60}
61
62/**
63 * Recognizes a number with up to 9 digits after the decimal point, followed by
64 * an "s", representing a number of seconds.
65 */
66const TIMEOUT_REGEX = /^\d+(\.\d{1,9})?s$/;
67
68/**
69 * Client language name used for determining whether this client matches a
70 * `ServiceConfigCanaryConfig`'s `clientLanguage` list.
71 */
72const CLIENT_LANGUAGE_STRING = 'node';
73
74function validateName(obj: any): MethodConfigName {
75 if (!('service' in obj) || typeof obj.service !== 'string') {
76 throw new Error('Invalid method config name: invalid service');
77 }
78 const result: MethodConfigName = {
79 service: obj.service,
80 };
81 if ('method' in obj) {
82 if (typeof obj.method === 'string') {
83 result.method = obj.method;
84 } else {
85 throw new Error('Invalid method config name: invalid method');
86 }
87 }
88 return result;
89}
90
91function validateMethodConfig(obj: any): MethodConfig {
92 const result: MethodConfig = {
93 name: [],
94 };
95 if (!('name' in obj) || !Array.isArray(obj.name)) {
96 throw new Error('Invalid method config: invalid name array');
97 }
98 for (const name of obj.name) {
99 result.name.push(validateName(name));
100 }
101 if ('waitForReady' in obj) {
102 if (typeof obj.waitForReady !== 'boolean') {
103 throw new Error('Invalid method config: invalid waitForReady');
104 }
105 result.waitForReady = obj.waitForReady;
106 }
107 if ('timeout' in obj) {
108 if (typeof obj.timeout === 'object') {
109 if (
110 !('seconds' in obj.timeout) ||
111 !(typeof obj.timeout.seconds === 'number')
112 ) {
113 throw new Error('Invalid method config: invalid timeout.seconds');
114 }
115 if (
116 !('nanos' in obj.timeout) ||
117 !(typeof obj.timeout.nanos === 'number')
118 ) {
119 throw new Error('Invalid method config: invalid timeout.nanos');
120 }
121 result.timeout = obj.timeout;
122 } else if (
123 typeof obj.timeout === 'string' &&
124 TIMEOUT_REGEX.test(obj.timeout)
125 ) {
126 const timeoutParts = obj.timeout
127 .substring(0, obj.timeout.length - 1)
128 .split('.');
129 result.timeout = {
130 seconds: timeoutParts[0] | 0,
131 nanos: (timeoutParts[1] ?? 0) | 0,
132 };
133 } else {
134 throw new Error('Invalid method config: invalid timeout');
135 }
136 }
137 if ('maxRequestBytes' in obj) {
138 if (typeof obj.maxRequestBytes !== 'number') {
139 throw new Error('Invalid method config: invalid maxRequestBytes');
140 }
141 result.maxRequestBytes = obj.maxRequestBytes;
142 }
143 if ('maxResponseBytes' in obj) {
144 if (typeof obj.maxResponseBytes !== 'number') {
145 throw new Error('Invalid method config: invalid maxRequestBytes');
146 }
147 result.maxResponseBytes = obj.maxResponseBytes;
148 }
149 return result;
150}
151
152export function validateServiceConfig(obj: any): ServiceConfig {
153 const result: ServiceConfig = {
154 loadBalancingConfig: [],
155 methodConfig: [],
156 };
157 if ('loadBalancingPolicy' in obj) {
158 if (typeof obj.loadBalancingPolicy === 'string') {
159 result.loadBalancingPolicy = obj.loadBalancingPolicy;
160 } else {
161 throw new Error('Invalid service config: invalid loadBalancingPolicy');
162 }
163 }
164 if ('loadBalancingConfig' in obj) {
165 if (Array.isArray(obj.loadBalancingConfig)) {
166 for (const config of obj.loadBalancingConfig) {
167 result.loadBalancingConfig.push(validateLoadBalancingConfig(config));
168 }
169 } else {
170 throw new Error('Invalid service config: invalid loadBalancingConfig');
171 }
172 }
173 if ('methodConfig' in obj) {
174 if (Array.isArray(obj.methodConfig)) {
175 for (const methodConfig of obj.methodConfig) {
176 result.methodConfig.push(validateMethodConfig(methodConfig));
177 }
178 }
179 }
180 // Validate method name uniqueness
181 const seenMethodNames: MethodConfigName[] = [];
182 for (const methodConfig of result.methodConfig) {
183 for (const name of methodConfig.name) {
184 for (const seenName of seenMethodNames) {
185 if (
186 name.service === seenName.service &&
187 name.method === seenName.method
188 ) {
189 throw new Error(
190 `Invalid service config: duplicate name ${name.service}/${name.method}`
191 );
192 }
193 }
194 seenMethodNames.push(name);
195 }
196 }
197 return result;
198}
199
200function validateCanaryConfig(obj: any): ServiceConfigCanaryConfig {
201 if (!('serviceConfig' in obj)) {
202 throw new Error('Invalid service config choice: missing service config');
203 }
204 const result: ServiceConfigCanaryConfig = {
205 serviceConfig: validateServiceConfig(obj.serviceConfig),
206 };
207 if ('clientLanguage' in obj) {
208 if (Array.isArray(obj.clientLanguage)) {
209 result.clientLanguage = [];
210 for (const lang of obj.clientLanguage) {
211 if (typeof lang === 'string') {
212 result.clientLanguage.push(lang);
213 } else {
214 throw new Error(
215 'Invalid service config choice: invalid clientLanguage'
216 );
217 }
218 }
219 } else {
220 throw new Error('Invalid service config choice: invalid clientLanguage');
221 }
222 }
223 if ('clientHostname' in obj) {
224 if (Array.isArray(obj.clientHostname)) {
225 result.clientHostname = [];
226 for (const lang of obj.clientHostname) {
227 if (typeof lang === 'string') {
228 result.clientHostname.push(lang);
229 } else {
230 throw new Error(
231 'Invalid service config choice: invalid clientHostname'
232 );
233 }
234 }
235 } else {
236 throw new Error('Invalid service config choice: invalid clientHostname');
237 }
238 }
239 if ('percentage' in obj) {
240 if (
241 typeof obj.percentage === 'number' &&
242 0 <= obj.percentage &&
243 obj.percentage <= 100
244 ) {
245 result.percentage = obj.percentage;
246 } else {
247 throw new Error('Invalid service config choice: invalid percentage');
248 }
249 }
250 // Validate that no unexpected fields are present
251 const allowedFields = [
252 'clientLanguage',
253 'percentage',
254 'clientHostname',
255 'serviceConfig',
256 ];
257 for (const field in obj) {
258 if (!allowedFields.includes(field)) {
259 throw new Error(
260 `Invalid service config choice: unexpected field ${field}`
261 );
262 }
263 }
264 return result;
265}
266
267function validateAndSelectCanaryConfig(
268 obj: any,
269 percentage: number
270): ServiceConfig {
271 if (!Array.isArray(obj)) {
272 throw new Error('Invalid service config list');
273 }
274 for (const config of obj) {
275 const validatedConfig = validateCanaryConfig(config);
276 /* For each field, we check if it is present, then only discard the
277 * config if the field value does not match the current client */
278 if (
279 typeof validatedConfig.percentage === 'number' &&
280 percentage > validatedConfig.percentage
281 ) {
282 continue;
283 }
284 if (Array.isArray(validatedConfig.clientHostname)) {
285 let hostnameMatched = false;
286 for (const hostname of validatedConfig.clientHostname) {
287 if (hostname === os.hostname()) {
288 hostnameMatched = true;
289 }
290 }
291 if (!hostnameMatched) {
292 continue;
293 }
294 }
295 if (Array.isArray(validatedConfig.clientLanguage)) {
296 let languageMatched = false;
297 for (const language of validatedConfig.clientLanguage) {
298 if (language === CLIENT_LANGUAGE_STRING) {
299 languageMatched = true;
300 }
301 }
302 if (!languageMatched) {
303 continue;
304 }
305 }
306 return validatedConfig.serviceConfig;
307 }
308 throw new Error('No matching service config found');
309}
310
311/**
312 * Find the "grpc_config" record among the TXT records, parse its value as JSON, validate its contents,
313 * and select a service config with selection fields that all match this client. Most of these steps
314 * can fail with an error; the caller must handle any errors thrown this way.
315 * @param txtRecord The TXT record array that is output from a successful call to dns.resolveTxt
316 * @param percentage A number chosen from the range [0, 100) that is used to select which config to use
317 * @return The service configuration to use, given the percentage value, or null if the service config
318 * data has a valid format but none of the options match the current client.
319 */
320export function extractAndSelectServiceConfig(
321 txtRecord: string[][],
322 percentage: number
323): ServiceConfig | null {
324 for (const record of txtRecord) {
325 if (record.length > 0 && record[0].startsWith('grpc_config=')) {
326 /* Treat the list of strings in this record as a single string and remove
327 * "grpc_config=" from the beginning. The rest should be a JSON string */
328 const recordString = record.join('').substring('grpc_config='.length);
329 const recordJson: any = JSON.parse(recordString);
330 return validateAndSelectCanaryConfig(recordJson, percentage);
331 }
332 }
333 return null;
334}