1 /* 2 * Copyright 2022 The 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 package io.grpc.xds; 18 19 import com.google.common.collect.ImmutableList; 20 import com.google.common.collect.ImmutableMap; 21 import com.google.common.collect.Iterables; 22 import com.google.protobuf.Any; 23 import com.google.protobuf.InvalidProtocolBufferException; 24 import com.google.protobuf.Struct; 25 import com.google.protobuf.util.Durations; 26 import com.google.protobuf.util.JsonFormat; 27 import io.envoyproxy.envoy.config.cluster.v3.Cluster; 28 import io.envoyproxy.envoy.config.cluster.v3.Cluster.LeastRequestLbConfig; 29 import io.envoyproxy.envoy.config.cluster.v3.Cluster.RingHashLbConfig; 30 import io.envoyproxy.envoy.config.cluster.v3.LoadBalancingPolicy; 31 import io.envoyproxy.envoy.config.cluster.v3.LoadBalancingPolicy.Policy; 32 import io.envoyproxy.envoy.extensions.load_balancing_policies.client_side_weighted_round_robin.v3.ClientSideWeightedRoundRobin; 33 import io.envoyproxy.envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest; 34 import io.envoyproxy.envoy.extensions.load_balancing_policies.pick_first.v3.PickFirst; 35 import io.envoyproxy.envoy.extensions.load_balancing_policies.ring_hash.v3.RingHash; 36 import io.envoyproxy.envoy.extensions.load_balancing_policies.round_robin.v3.RoundRobin; 37 import io.envoyproxy.envoy.extensions.load_balancing_policies.wrr_locality.v3.WrrLocality; 38 import io.grpc.InternalLogId; 39 import io.grpc.LoadBalancerRegistry; 40 import io.grpc.internal.JsonParser; 41 import io.grpc.xds.LoadBalancerConfigFactory.LoadBalancingPolicyConverter.MaxRecursionReachedException; 42 import io.grpc.xds.XdsClientImpl.ResourceInvalidException; 43 import io.grpc.xds.XdsLogger.XdsLogLevel; 44 import java.io.IOException; 45 import java.util.Map; 46 47 /** 48 * Creates service config JSON load balancer config objects for a given xDS Cluster message. 49 * Supports both the "legacy" configuration style and the new, more advanced one that utilizes the 50 * xDS "typed extension" mechanism. 51 * 52 * <p>Legacy configuration is done by setting the lb_policy enum field and any supporting 53 * configuration fields needed by the particular policy. 54 * 55 * <p>The new approach is to set the load_balancing_policy field that contains both the policy 56 * selection as well as any supporting configuration data. Providing a list of acceptable policies 57 * is also supported. Note that if this field is used, it will override any configuration set using 58 * the legacy approach. The new configuration approach is explained in detail in the <a href=" 59 * https://github.com/grpc/proposal/blob/master/A52-xds-custom-lb-policies.md">Custom LB Policies 60 * gRFC</a> 61 */ 62 class LoadBalancerConfigFactory { 63 64 private static final XdsLogger logger = XdsLogger.withLogId( 65 InternalLogId.allocate("xds-client-lbconfig-factory", null)); 66 67 static final String ROUND_ROBIN_FIELD_NAME = "round_robin"; 68 69 static final String RING_HASH_FIELD_NAME = "ring_hash_experimental"; 70 static final String MIN_RING_SIZE_FIELD_NAME = "minRingSize"; 71 static final String MAX_RING_SIZE_FIELD_NAME = "maxRingSize"; 72 73 static final String LEAST_REQUEST_FIELD_NAME = "least_request_experimental"; 74 static final String CHOICE_COUNT_FIELD_NAME = "choiceCount"; 75 76 static final String WRR_LOCALITY_FIELD_NAME = "wrr_locality_experimental"; 77 static final String CHILD_POLICY_FIELD = "childPolicy"; 78 79 static final String BLACK_OUT_PERIOD = "blackoutPeriod"; 80 81 static final String WEIGHT_EXPIRATION_PERIOD = "weightExpirationPeriod"; 82 83 static final String OOB_REPORTING_PERIOD = "oobReportingPeriod"; 84 85 static final String ENABLE_OOB_LOAD_REPORT = "enableOobLoadReport"; 86 87 static final String WEIGHT_UPDATE_PERIOD = "weightUpdatePeriod"; 88 89 static final String PICK_FIRST_FIELD_NAME = "pick_first"; 90 static final String SHUFFLE_ADDRESS_LIST_FIELD_NAME = "shuffleAddressList"; 91 92 static final String ERROR_UTILIZATION_PENALTY = "errorUtilizationPenalty"; 93 94 /** 95 * Factory method for creating a new {link LoadBalancerConfigConverter} for a given xDS {@link 96 * Cluster}. 97 * 98 * @throws ResourceInvalidException If the {@link Cluster} has an invalid LB configuration. 99 */ newConfig(Cluster cluster, boolean enableLeastRequest, boolean enableWrr, boolean enablePickFirst)100 static ImmutableMap<String, ?> newConfig(Cluster cluster, boolean enableLeastRequest, 101 boolean enableWrr, boolean enablePickFirst) 102 throws ResourceInvalidException { 103 // The new load_balancing_policy will always be used if it is set, but for backward 104 // compatibility we will fall back to using the old lb_policy field if the new field is not set. 105 if (cluster.hasLoadBalancingPolicy()) { 106 try { 107 return LoadBalancingPolicyConverter.convertToServiceConfig(cluster.getLoadBalancingPolicy(), 108 0, enableWrr, enablePickFirst); 109 } catch (MaxRecursionReachedException e) { 110 throw new ResourceInvalidException("Maximum LB config recursion depth reached", e); 111 } 112 } else { 113 return LegacyLoadBalancingPolicyConverter.convertToServiceConfig(cluster, enableLeastRequest); 114 } 115 } 116 117 /** 118 * Builds a service config JSON object for the ring_hash load balancer config based on the given 119 * config values. 120 */ buildRingHashConfig(Long minRingSize, Long maxRingSize)121 private static ImmutableMap<String, ?> buildRingHashConfig(Long minRingSize, Long maxRingSize) { 122 ImmutableMap.Builder<String, Object> configBuilder = ImmutableMap.builder(); 123 if (minRingSize != null) { 124 configBuilder.put(MIN_RING_SIZE_FIELD_NAME, minRingSize.doubleValue()); 125 } 126 if (maxRingSize != null) { 127 configBuilder.put(MAX_RING_SIZE_FIELD_NAME, maxRingSize.doubleValue()); 128 } 129 return ImmutableMap.of(RING_HASH_FIELD_NAME, configBuilder.buildOrThrow()); 130 } 131 132 /** 133 * Builds a service config JSON object for the weighted_round_robin load balancer config based on 134 * the given config values. 135 */ buildWrrConfig(String blackoutPeriod, String weightExpirationPeriod, String oobReportingPeriod, Boolean enableOobLoadReport, String weightUpdatePeriod, Float errorUtilizationPenalty)136 private static ImmutableMap<String, ?> buildWrrConfig(String blackoutPeriod, 137 String weightExpirationPeriod, 138 String oobReportingPeriod, 139 Boolean enableOobLoadReport, 140 String weightUpdatePeriod, 141 Float errorUtilizationPenalty) { 142 ImmutableMap.Builder<String, Object> configBuilder = ImmutableMap.builder(); 143 if (blackoutPeriod != null) { 144 configBuilder.put(BLACK_OUT_PERIOD, blackoutPeriod); 145 } 146 if (weightExpirationPeriod != null) { 147 configBuilder.put(WEIGHT_EXPIRATION_PERIOD, weightExpirationPeriod); 148 } 149 if (oobReportingPeriod != null) { 150 configBuilder.put(OOB_REPORTING_PERIOD, oobReportingPeriod); 151 } 152 if (enableOobLoadReport != null) { 153 configBuilder.put(ENABLE_OOB_LOAD_REPORT, enableOobLoadReport); 154 } 155 if (weightUpdatePeriod != null) { 156 configBuilder.put(WEIGHT_UPDATE_PERIOD, weightUpdatePeriod); 157 } 158 if (errorUtilizationPenalty != null) { 159 configBuilder.put(ERROR_UTILIZATION_PENALTY, errorUtilizationPenalty); 160 } 161 return ImmutableMap.of(WeightedRoundRobinLoadBalancerProvider.SCHEME, 162 configBuilder.buildOrThrow()); 163 } 164 165 /** 166 * Builds a service config JSON object for the least_request load balancer config based on the 167 * given config values. 168 */ buildLeastRequestConfig(Integer choiceCount)169 private static ImmutableMap<String, ?> buildLeastRequestConfig(Integer choiceCount) { 170 ImmutableMap.Builder<String, Object> configBuilder = ImmutableMap.builder(); 171 if (choiceCount != null) { 172 configBuilder.put(CHOICE_COUNT_FIELD_NAME, choiceCount.doubleValue()); 173 } 174 return ImmutableMap.of(LEAST_REQUEST_FIELD_NAME, configBuilder.buildOrThrow()); 175 } 176 177 /** 178 * Builds a service config JSON wrr_locality by wrapping another policy config. 179 */ buildWrrLocalityConfig( ImmutableMap<String, ?> childConfig)180 private static ImmutableMap<String, ?> buildWrrLocalityConfig( 181 ImmutableMap<String, ?> childConfig) { 182 return ImmutableMap.<String, Object>builder().put(WRR_LOCALITY_FIELD_NAME, 183 ImmutableMap.of(CHILD_POLICY_FIELD, ImmutableList.of(childConfig))).buildOrThrow(); 184 } 185 186 /** 187 * Builds an empty service config JSON config object for round robin (it is not configurable). 188 */ buildRoundRobinConfig()189 private static ImmutableMap<String, ?> buildRoundRobinConfig() { 190 return ImmutableMap.of(ROUND_ROBIN_FIELD_NAME, ImmutableMap.of()); 191 } 192 193 /** 194 * Builds a service config JSON object for the pick_first load balancer config based on the 195 * given config values. 196 */ buildPickFirstConfig(boolean shuffleAddressList)197 private static ImmutableMap<String, ?> buildPickFirstConfig(boolean shuffleAddressList) { 198 ImmutableMap.Builder<String, Object> configBuilder = ImmutableMap.builder(); 199 configBuilder.put(SHUFFLE_ADDRESS_LIST_FIELD_NAME, shuffleAddressList); 200 return ImmutableMap.of(PICK_FIRST_FIELD_NAME, configBuilder.buildOrThrow()); 201 } 202 203 /** 204 * Responsible for converting from a {@code envoy.config.cluster.v3.LoadBalancingPolicy} proto 205 * message to a gRPC service config format. 206 */ 207 static class LoadBalancingPolicyConverter { 208 209 private static final int MAX_RECURSION = 16; 210 211 /** 212 * Converts a {@link LoadBalancingPolicy} object to a service config JSON object. 213 */ convertToServiceConfig( LoadBalancingPolicy loadBalancingPolicy, int recursionDepth, boolean enableWrr, boolean enablePickFirst)214 private static ImmutableMap<String, ?> convertToServiceConfig( 215 LoadBalancingPolicy loadBalancingPolicy, int recursionDepth, boolean enableWrr, 216 boolean enablePickFirst) 217 throws ResourceInvalidException, MaxRecursionReachedException { 218 if (recursionDepth > MAX_RECURSION) { 219 throw new MaxRecursionReachedException(); 220 } 221 ImmutableMap<String, ?> serviceConfig = null; 222 223 for (Policy policy : loadBalancingPolicy.getPoliciesList()) { 224 Any typedConfig = policy.getTypedExtensionConfig().getTypedConfig(); 225 try { 226 if (typedConfig.is(RingHash.class)) { 227 serviceConfig = convertRingHashConfig(typedConfig.unpack(RingHash.class)); 228 } else if (typedConfig.is(WrrLocality.class)) { 229 serviceConfig = convertWrrLocalityConfig(typedConfig.unpack(WrrLocality.class), 230 recursionDepth, enableWrr, enablePickFirst); 231 } else if (typedConfig.is(RoundRobin.class)) { 232 serviceConfig = convertRoundRobinConfig(); 233 } else if (typedConfig.is(LeastRequest.class)) { 234 serviceConfig = convertLeastRequestConfig(typedConfig.unpack(LeastRequest.class)); 235 } else if (typedConfig.is(ClientSideWeightedRoundRobin.class)) { 236 if (enableWrr) { 237 serviceConfig = convertWeightedRoundRobinConfig( 238 typedConfig.unpack(ClientSideWeightedRoundRobin.class)); 239 } 240 } else if (typedConfig.is(PickFirst.class)) { 241 if (enablePickFirst) { 242 serviceConfig = convertPickFirstConfig(typedConfig.unpack(PickFirst.class)); 243 } 244 } else if (typedConfig.is(com.github.xds.type.v3.TypedStruct.class)) { 245 serviceConfig = convertCustomConfig( 246 typedConfig.unpack(com.github.xds.type.v3.TypedStruct.class)); 247 } else if (typedConfig.is(com.github.udpa.udpa.type.v1.TypedStruct.class)) { 248 serviceConfig = convertCustomConfig( 249 typedConfig.unpack(com.github.udpa.udpa.type.v1.TypedStruct.class)); 250 } 251 252 // TODO: support least_request once it is added to the envoy protos. 253 } catch (InvalidProtocolBufferException e) { 254 throw new ResourceInvalidException( 255 "Unable to unpack typedConfig for: " + typedConfig.getTypeUrl(), e); 256 } 257 // The service config is expected to have a single root entry, where the name of that entry 258 // is the name of the policy. A Load balancer with this name must exist in the registry. 259 if (serviceConfig == null || LoadBalancerRegistry.getDefaultRegistry() 260 .getProvider(Iterables.getOnlyElement(serviceConfig.keySet())) == null) { 261 logger.log(XdsLogLevel.WARNING, "Policy {0} not found in the LB registry, skipping", 262 typedConfig.getTypeUrl()); 263 continue; 264 } else { 265 return serviceConfig; 266 } 267 } 268 269 // If we could not find a Policy that we could both convert as well as find a provider for 270 // then we have an invalid LB policy configuration. 271 throw new ResourceInvalidException("Invalid LoadBalancingPolicy: " + loadBalancingPolicy); 272 } 273 274 /** 275 * Converts a ring_hash {@link Any} configuration to service config format. 276 */ convertRingHashConfig(RingHash ringHash)277 private static ImmutableMap<String, ?> convertRingHashConfig(RingHash ringHash) 278 throws ResourceInvalidException { 279 // The hash function needs to be validated here as it is not exposed in the returned 280 // configuration for later validation. 281 if (RingHash.HashFunction.XX_HASH != ringHash.getHashFunction()) { 282 throw new ResourceInvalidException( 283 "Invalid ring hash function: " + ringHash.getHashFunction()); 284 } 285 286 return buildRingHashConfig( 287 ringHash.hasMinimumRingSize() ? ringHash.getMinimumRingSize().getValue() : null, 288 ringHash.hasMaximumRingSize() ? ringHash.getMaximumRingSize().getValue() : null); 289 } 290 convertWeightedRoundRobinConfig( ClientSideWeightedRoundRobin wrr)291 private static ImmutableMap<String, ?> convertWeightedRoundRobinConfig( 292 ClientSideWeightedRoundRobin wrr) throws ResourceInvalidException { 293 try { 294 return buildWrrConfig( 295 wrr.hasBlackoutPeriod() ? Durations.toString(wrr.getBlackoutPeriod()) : null, 296 wrr.hasWeightExpirationPeriod() 297 ? Durations.toString(wrr.getWeightExpirationPeriod()) : null, 298 wrr.hasOobReportingPeriod() ? Durations.toString(wrr.getOobReportingPeriod()) : null, 299 wrr.hasEnableOobLoadReport() ? wrr.getEnableOobLoadReport().getValue() : null, 300 wrr.hasWeightUpdatePeriod() ? Durations.toString(wrr.getWeightUpdatePeriod()) : null, 301 wrr.hasErrorUtilizationPenalty() ? wrr.getErrorUtilizationPenalty().getValue() : null); 302 } catch (IllegalArgumentException ex) { 303 throw new ResourceInvalidException("Invalid duration in weighted round robin config: " 304 + ex.getMessage()); 305 } 306 } 307 308 /** 309 * Converts a wrr_locality {@link Any} configuration to service config format. 310 */ convertWrrLocalityConfig(WrrLocality wrrLocality, int recursionDepth, boolean enableWrr, boolean enablePickFirst)311 private static ImmutableMap<String, ?> convertWrrLocalityConfig(WrrLocality wrrLocality, 312 int recursionDepth, boolean enableWrr, boolean enablePickFirst) 313 throws ResourceInvalidException, 314 MaxRecursionReachedException { 315 return buildWrrLocalityConfig( 316 convertToServiceConfig(wrrLocality.getEndpointPickingPolicy(), 317 recursionDepth + 1, enableWrr, enablePickFirst)); 318 } 319 320 /** 321 * "Converts" a round_robin configuration to service config format. 322 */ convertRoundRobinConfig()323 private static ImmutableMap<String, ?> convertRoundRobinConfig() { 324 return buildRoundRobinConfig(); 325 } 326 327 /** 328 * "Converts" a pick_first configuration to service config format. 329 */ convertPickFirstConfig(PickFirst pickFirst)330 private static ImmutableMap<String, ?> convertPickFirstConfig(PickFirst pickFirst) { 331 return buildPickFirstConfig(pickFirst.getShuffleAddressList()); 332 } 333 334 /** 335 * Converts a least_request {@link Any} configuration to service config format. 336 */ convertLeastRequestConfig(LeastRequest leastRequest)337 private static ImmutableMap<String, ?> convertLeastRequestConfig(LeastRequest leastRequest) 338 throws ResourceInvalidException { 339 return buildLeastRequestConfig( 340 leastRequest.hasChoiceCount() ? leastRequest.getChoiceCount().getValue() : null); 341 } 342 343 /** 344 * Converts a custom TypedStruct LB config to service config format. 345 */ 346 @SuppressWarnings("unchecked") convertCustomConfig( com.github.xds.type.v3.TypedStruct configTypedStruct)347 private static ImmutableMap<String, ?> convertCustomConfig( 348 com.github.xds.type.v3.TypedStruct configTypedStruct) 349 throws ResourceInvalidException { 350 return ImmutableMap.of(parseCustomConfigTypeName(configTypedStruct.getTypeUrl()), 351 (Map<String, ?>) parseCustomConfigJson(configTypedStruct.getValue())); 352 } 353 354 /** 355 * Converts a custom UDPA (legacy) TypedStruct LB config to service config format. 356 */ 357 @SuppressWarnings("unchecked") convertCustomConfig( com.github.udpa.udpa.type.v1.TypedStruct configTypedStruct)358 private static ImmutableMap<String, ?> convertCustomConfig( 359 com.github.udpa.udpa.type.v1.TypedStruct configTypedStruct) 360 throws ResourceInvalidException { 361 return ImmutableMap.of(parseCustomConfigTypeName(configTypedStruct.getTypeUrl()), 362 (Map<String, ?>) parseCustomConfigJson(configTypedStruct.getValue())); 363 } 364 365 /** 366 * Print the config Struct into JSON and then parse that into our internal representation. 367 */ parseCustomConfigJson(Struct configStruct)368 private static Object parseCustomConfigJson(Struct configStruct) 369 throws ResourceInvalidException { 370 Object rawJsonConfig = null; 371 try { 372 rawJsonConfig = JsonParser.parse(JsonFormat.printer().print(configStruct)); 373 } catch (IOException e) { 374 throw new ResourceInvalidException("Unable to parse custom LB config JSON", e); 375 } 376 377 if (!(rawJsonConfig instanceof Map)) { 378 throw new ResourceInvalidException("Custom LB config does not contain a JSON object"); 379 } 380 return rawJsonConfig; 381 } 382 383 parseCustomConfigTypeName(String customConfigTypeName)384 private static String parseCustomConfigTypeName(String customConfigTypeName) { 385 if (customConfigTypeName.contains("/")) { 386 customConfigTypeName = customConfigTypeName.substring( 387 customConfigTypeName.lastIndexOf("/") + 1); 388 } 389 return customConfigTypeName; 390 } 391 392 // Used to signal that the LB config goes too deep. 393 static class MaxRecursionReachedException extends Exception { 394 static final long serialVersionUID = 1L; 395 } 396 } 397 398 /** 399 * Builds a JSON LB configuration based on the old style of using the xDS Cluster proto message. 400 * The lb_policy field is used to select the policy and configuration is extracted from various 401 * policy specific fields in Cluster. 402 */ 403 static class LegacyLoadBalancingPolicyConverter { 404 405 /** 406 * Factory method for creating a new {link LoadBalancerConfigConverter} for a given xDS {@link 407 * Cluster}. 408 * 409 * @throws ResourceInvalidException If the {@link Cluster} has an invalid LB configuration. 410 */ convertToServiceConfig(Cluster cluster, boolean enableLeastRequest)411 static ImmutableMap<String, ?> convertToServiceConfig(Cluster cluster, 412 boolean enableLeastRequest) throws ResourceInvalidException { 413 switch (cluster.getLbPolicy()) { 414 case RING_HASH: 415 return convertRingHashConfig(cluster); 416 case ROUND_ROBIN: 417 return buildWrrLocalityConfig(buildRoundRobinConfig()); 418 case LEAST_REQUEST: 419 if (enableLeastRequest) { 420 return buildWrrLocalityConfig(convertLeastRequestConfig(cluster)); 421 } 422 break; 423 default: 424 } 425 throw new ResourceInvalidException( 426 "Cluster " + cluster.getName() + ": unsupported lb policy: " + cluster.getLbPolicy()); 427 } 428 429 /** 430 * Creates a new ring_hash service config JSON object based on the old {@link RingHashLbConfig} 431 * config message. 432 */ convertRingHashConfig(Cluster cluster)433 private static ImmutableMap<String, ?> convertRingHashConfig(Cluster cluster) 434 throws ResourceInvalidException { 435 RingHashLbConfig lbConfig = cluster.getRingHashLbConfig(); 436 437 // The hash function needs to be validated here as it is not exposed in the returned 438 // configuration for later validation. 439 if (lbConfig.getHashFunction() != RingHashLbConfig.HashFunction.XX_HASH) { 440 throw new ResourceInvalidException( 441 "Cluster " + cluster.getName() + ": invalid ring hash function: " + lbConfig); 442 } 443 444 return buildRingHashConfig( 445 lbConfig.hasMinimumRingSize() ? (Long) lbConfig.getMinimumRingSize().getValue() : null, 446 lbConfig.hasMaximumRingSize() ? (Long) lbConfig.getMaximumRingSize().getValue() : null); 447 } 448 449 /** 450 * Creates a new least_request service config JSON object based on the old {@link 451 * LeastRequestLbConfig} config message. 452 */ convertLeastRequestConfig(Cluster cluster)453 private static ImmutableMap<String, ?> convertLeastRequestConfig(Cluster cluster) { 454 LeastRequestLbConfig lbConfig = cluster.getLeastRequestLbConfig(); 455 return buildLeastRequestConfig( 456 lbConfig.hasChoiceCount() ? (Integer) lbConfig.getChoiceCount().getValue() : null); 457 } 458 } 459 } 460