codekingpro/portable-devtools
114k
1# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.2#3# Licensed under the Apache License, Version 2.0 (the "License"). You4# may not use this file except in compliance with the License. A copy of5# the License is located at6#7# http://aws.amazon.com/apache2.0/8#9# or in the "license" file accompanying this file. This file is10# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF11# ANY KIND, either express or implied. See the License for the specific12# language governing permissions and limitations under the License.13 14"""15NOTE: All classes and functions in this module are considered private and are16subject to abrupt breaking changes. Please do not use them directly.17 18To view the raw JSON that the objects in this module represent, please19go to any `endpoint-rule-set.json` file in /botocore/data/<service>/<api version>/20or you can look at the test files in /tests/unit/data/endpoints/valid-rules/21"""22 23 24import logging25import re26from enum import Enum27from string import Formatter28from typing import NamedTuple29 30from botocore import xform_name31from botocore.compat import IPV4_RE, quote, urlparse32from botocore.exceptions import EndpointResolutionError33from botocore.utils import (34 ArnParser,35 InvalidArnException,36 is_valid_ipv4_endpoint_url,37 is_valid_ipv6_endpoint_url,38 lru_cache_weakref,39 normalize_url_path,40 percent_encode,41)42 43logger = logging.getLogger(__name__)44 45TEMPLATE_STRING_RE = re.compile(r"\{[a-zA-Z#]+\}")46GET_ATTR_RE = re.compile(r"(\w+)\[(\d+)\]")47VALID_HOST_LABEL_RE = re.compile(48 r"^(?!-)[a-zA-Z\d-]{1,63}(?<!-)$",49)50CACHE_SIZE = 10051ARN_PARSER = ArnParser()52STRING_FORMATTER = Formatter()53 54 55class RuleSetStandardLibrary:56 """Rule actions to be performed by the EndpointProvider."""57 58 def __init__(self, partitions_data):59 self.partitions_data = partitions_data60 61 def is_func(self, argument):62 """Determine if an object is a function object.63 64 :type argument: Any65 :rtype: bool66 """67 return isinstance(argument, dict) and "fn" in argument68 69 def is_ref(self, argument):70 """Determine if an object is a reference object.71 72 :type argument: Any73 :rtype: bool74 """75 return isinstance(argument, dict) and "ref" in argument76 77 def is_template(self, argument):78 """Determine if an object contains a template string.79 80 :type argument: Any81 :rtpe: bool82 """83 return (84 isinstance(argument, str)85 and TEMPLATE_STRING_RE.search(argument) is not None86 )87 88 def resolve_template_string(self, value, scope_vars):89 """Resolve and inject values into a template string.90 91 :type value: str92 :type scope_vars: dict93 :rtype: str94 """95 result = ""96 for literal, reference, _, _ in STRING_FORMATTER.parse(value):97 if reference is not None:98 template_value = scope_vars99 template_params = reference.split("#")100 for param in template_params:101 template_value = template_value[param]102 result += f"{literal}{template_value}"103 else:104 result += literal105 return result106 107 def resolve_value(self, value, scope_vars):108 """Return evaluated value based on type.109 110 :type value: Any111 :type scope_vars: dict112 :rtype: Any113 """114 if self.is_func(value):115 return self.call_function(value, scope_vars)116 elif self.is_ref(value):117 return scope_vars.get(value["ref"])118 elif self.is_template(value):119 return self.resolve_template_string(value, scope_vars)120 121 return value122 123 def convert_func_name(self, value):124 """Normalize function names.125 126 :type value: str127 :rtype: str128 """129 normalized_name = f"{xform_name(value)}"130 if normalized_name == "not":131 normalized_name = f"_{normalized_name}"132 return normalized_name.replace(".", "_")133 134 def call_function(self, func_signature, scope_vars):135 """Call the function with the resolved arguments and assign to `scope_vars`136 when applicable.137 138 :type func_signature: dict139 :type scope_vars: dict140 :rtype: Any141 """142 func_args = [143 self.resolve_value(arg, scope_vars)144 for arg in func_signature["argv"]145 ]146 func_name = self.convert_func_name(func_signature["fn"])147 func = getattr(self, func_name)148 result = func(*func_args)149 if "assign" in func_signature:150 assign = func_signature["assign"]151 if assign in scope_vars:152 raise EndpointResolutionError(153 msg=f"Assignment {assign} already exists in "154 "scoped variables and cannot be overwritten"155 )156 scope_vars[assign] = result157 return result158 159 def is_set(self, value):160 """Evaluates whether a value is set.161 162 :type value: Any163 :rytpe: bool164 """165 return value is not None166 167 def get_attr(self, value, path):168 """Find an attribute within a value given a path string. The path can contain169 the name of the attribute and an index in brackets. A period separating attribute170 names indicates the one to the right is nested. The index will always occur at171 the end of the path.172 173 :type value: dict or list174 :type path: str175 :rtype: Any176 """177 for part in path.split("."):178 match = GET_ATTR_RE.search(part)179 if match is not None:180 name, index = match.groups()181 index = int(index)182 value = value.get(name)183 if value is None or index >= len(value):184 return None185 return value[index]186 else:187 value = value[part]188 return value189 190 def format_partition_output(self, partition):191 output = partition["outputs"]192 output["name"] = partition["id"]193 return output194 195 def is_partition_match(self, region, partition):196 matches_regex = re.match(partition["regionRegex"], region) is not None197 return region in partition["regions"] or matches_regex198 199 def aws_partition(self, value):200 """Match a region string to an AWS partition.201 202 :type value: str203 :rtype: dict204 """205 partitions = self.partitions_data['partitions']206 207 if value is not None:208 for partition in partitions:209 if self.is_partition_match(value, partition):210 return self.format_partition_output(partition)211 212 # return the default partition if no matches were found213 aws_partition = partitions[0]214 return self.format_partition_output(aws_partition)215 216 def aws_parse_arn(self, value):217 """Parse and validate string for ARN components.218 219 :type value: str220 :rtype: dict221 """222 if value is None or not value.startswith("arn:"):223 return None224 225 try:226 arn_dict = ARN_PARSER.parse_arn(value)227 except InvalidArnException:228 return None229 230 # partition, resource, and service are required231 if not all(232 (arn_dict["partition"], arn_dict["service"], arn_dict["resource"])233 ):234 return None235 236 arn_dict["accountId"] = arn_dict.pop("account")237 238 resource = arn_dict.pop("resource")239 arn_dict["resourceId"] = resource.replace(":", "/").split("/")240 241 return arn_dict242 243 def is_valid_host_label(self, value, allow_subdomains):244 """Evaluates whether a value is a valid host label per245 RFC 1123. If allow_subdomains is True, split on `.` and validate246 each component separately.247 248 :type value: str249 :type allow_subdomains: bool250 :rtype: bool251 """252 if value is None or allow_subdomains is False and value.count(".") > 0:253 return False254 255 if allow_subdomains is True:256 return all(257 self.is_valid_host_label(label, False)258 for label in value.split(".")259 )260 261 return VALID_HOST_LABEL_RE.match(value) is not None262 263 def string_equals(self, value1, value2):264 """Evaluates two string values for equality.265 266 :type value1: str267 :type value2: str268 :rtype: bool269 """270 if not all(isinstance(val, str) for val in (value1, value2)):271 msg = f"Both values must be strings, not {type(value1)} and {type(value2)}."272 raise EndpointResolutionError(msg=msg)273 return value1 == value2274 275 def uri_encode(self, value):276 """Perform percent-encoding on an input string.277 278 :type value: str279 :rytpe: str280 """281 if value is None:282 return None283 284 return percent_encode(value)285 286 def parse_url(self, value):287 """Parse a URL string into components.288 289 :type value: str290 :rtype: dict291 """292 if value is None:293 return None294 295 url_components = urlparse(value)296 try:297 # url_parse may assign non-integer values to298 # `port` and will fail when accessed.299 url_components.port300 except ValueError:301 return None302 303 scheme = url_components.scheme304 query = url_components.query305 # URLs with queries are not supported306 if scheme not in ("https", "http") or len(query) > 0:307 return None308 309 path = url_components.path310 normalized_path = quote(normalize_url_path(path))311 if not normalized_path.endswith("/"):312 normalized_path = f"{normalized_path}/"313 314 return {315 "scheme": scheme,316 "authority": url_components.netloc,317 "path": path,318 "normalizedPath": normalized_path,319 "isIp": is_valid_ipv4_endpoint_url(value)320 or is_valid_ipv6_endpoint_url(value),321 }322 323 def boolean_equals(self, value1, value2):324 """Evaluates two boolean values for equality.325 326 :type value1: bool327 :type value2: bool328 :rtype: bool329 """330 if not all(isinstance(val, bool) for val in (value1, value2)):331 msg = f"Both arguments must be bools, not {type(value1)} and {type(value2)}."332 raise EndpointResolutionError(msg=msg)333 return value1 is value2334 335 def is_ascii(self, value):336 """Evaluates if a string only contains ASCII characters.337 338 :type value: str339 :rtype: bool340 """341 try:342 value.encode("ascii")343 return True344 except UnicodeEncodeError:345 return False346 347 def substring(self, value, start, stop, reverse):348 """Computes a substring given the start index and end index. If `reverse` is349 True, slice the string from the end instead.350 351 :type value: str352 :type start: int353 :type end: int354 :type reverse: bool355 :rtype: str356 """357 if not isinstance(value, str):358 msg = f"Input must be a string, not {type(value)}."359 raise EndpointResolutionError(msg=msg)360 if start >= stop or len(value) < stop or not self.is_ascii(value):361 return None362 363 if reverse is True:364 r_start = len(value) - stop365 r_stop = len(value) - start366 return value[r_start:r_stop]367 368 return value[start:stop]369 370 def _not(self, value):371 """A function implementation of the logical operator `not`.372 373 :type value: Any374 :rtype: bool375 """376 return not value377 378 def aws_is_virtual_hostable_s3_bucket(self, value, allow_subdomains):379 """Evaluates whether a value is a valid bucket name for virtual host380 style bucket URLs. To pass, the value must meet the following criteria:381 1. is_valid_host_label(value) is True382 2. length between 3 and 63 characters (inclusive)383 3. does not contain uppercase characters384 4. is not formatted as an IP address385 386 If allow_subdomains is True, split on `.` and validate387 each component separately.388 389 :type value: str390 :type allow_subdomains: bool391 :rtype: bool392 """393 if (394 value is None395 or len(value) < 3396 or value.lower() != value397 or IPV4_RE.match(value) is not None398 ):399 return False400 401 return self.is_valid_host_label(402 value, allow_subdomains=allow_subdomains403 )404 405 406# maintains backwards compatibility as `Library` was misspelled407# in earlier versions408RuleSetStandardLibary = RuleSetStandardLibrary409 410 411class BaseRule:412 """Base interface for individual endpoint rules."""413 414 def __init__(self, conditions, documentation=None):415 self.conditions = conditions416 self.documentation = documentation417 418 def evaluate(self, scope_vars, rule_lib):419 raise NotImplementedError()420 421 def evaluate_conditions(self, scope_vars, rule_lib):422 """Determine if all conditions in a rule are met.423 424 :type scope_vars: dict425 :type rule_lib: RuleSetStandardLibrary426 :rtype: bool427 """428 for func_signature in self.conditions:429 result = rule_lib.call_function(func_signature, scope_vars)430 if result is False or result is None:431 return False432 return True433 434 435class RuleSetEndpoint(NamedTuple):436 """A resolved endpoint object returned by a rule."""437 438 url: str439 properties: dict440 headers: dict441 442 443class EndpointRule(BaseRule):444 def __init__(self, endpoint, **kwargs):445 super().__init__(**kwargs)446 self.endpoint = endpoint447 448 def evaluate(self, scope_vars, rule_lib):449 """Determine if conditions are met to provide a valid endpoint.450 451 :type scope_vars: dict452 :rtype: RuleSetEndpoint453 """454 if self.evaluate_conditions(scope_vars, rule_lib):455 url = rule_lib.resolve_value(self.endpoint["url"], scope_vars)456 properties = self.resolve_properties(457 self.endpoint.get("properties", {}),458 scope_vars,459 rule_lib,460 )461 headers = self.resolve_headers(scope_vars, rule_lib)462 return RuleSetEndpoint(463 url=url, properties=properties, headers=headers464 )465 466 return None467 468 def resolve_properties(self, properties, scope_vars, rule_lib):469 """Traverse `properties` attribute, resolving any template strings.470 471 :type properties: dict/list/str472 :type scope_vars: dict473 :type rule_lib: RuleSetStandardLibrary474 :rtype: dict475 """476 if isinstance(properties, list):477 return [478 self.resolve_properties(prop, scope_vars, rule_lib)479 for prop in properties480 ]481 elif isinstance(properties, dict):482 return {483 key: self.resolve_properties(value, scope_vars, rule_lib)484 for key, value in properties.items()485 }486 elif rule_lib.is_template(properties):487 return rule_lib.resolve_template_string(properties, scope_vars)488 489 return properties490 491 def resolve_headers(self, scope_vars, rule_lib):492 """Iterate through headers attribute resolving all values.493 494 :type scope_vars: dict495 :type rule_lib: RuleSetStandardLibrary496 :rtype: dict497 """498 resolved_headers = {}499 headers = self.endpoint.get("headers", {})500 501 for header, values in headers.items():502 resolved_headers[header] = [503 rule_lib.resolve_value(item, scope_vars) for item in values504 ]505 return resolved_headers506 507 508class ErrorRule(BaseRule):509 def __init__(self, error, **kwargs):510 super().__init__(**kwargs)511 self.error = error512 513 def evaluate(self, scope_vars, rule_lib):514 """If an error rule's conditions are met, raise an error rule.515 516 :type scope_vars: dict517 :type rule_lib: RuleSetStandardLibrary518 :rtype: EndpointResolutionError519 """520 if self.evaluate_conditions(scope_vars, rule_lib):521 error = rule_lib.resolve_value(self.error, scope_vars)522 raise EndpointResolutionError(msg=error)523 return None524 525 526class TreeRule(BaseRule):527 """A tree rule is non-terminal meaning it will never be returned to a provider.528 Additionally this means it has no attributes that need to be resolved.529 """530 531 def __init__(self, rules, **kwargs):532 super().__init__(**kwargs)533 self.rules = [RuleCreator.create(**rule) for rule in rules]534 535 def evaluate(self, scope_vars, rule_lib):536 """If a tree rule's conditions are met, iterate its sub-rules537 and return first result found.538 539 :type scope_vars: dict540 :type rule_lib: RuleSetStandardLibrary541 :rtype: RuleSetEndpoint/EndpointResolutionError542 """543 if self.evaluate_conditions(scope_vars, rule_lib):544 for rule in self.rules:545 # don't share scope_vars between rules546 rule_result = rule.evaluate(scope_vars.copy(), rule_lib)547 if rule_result:548 return rule_result549 return None550 551 552class RuleCreator:553 endpoint = EndpointRule554 error = ErrorRule555 tree = TreeRule556 557 @classmethod558 def create(cls, **kwargs):559 """Create a rule instance from metadata.560 561 :rtype: TreeRule/EndpointRule/ErrorRule562 """563 rule_type = kwargs.pop("type")564 try:565 rule_class = getattr(cls, rule_type)566 except AttributeError:567 raise EndpointResolutionError(568 msg=f"Unknown rule type: {rule_type}. A rule must "569 "be of type tree, endpoint or error."570 )571 else:572 return rule_class(**kwargs)573 574 575class ParameterType(Enum):576 """Translation from `type` attribute to native Python type."""577 578 string = str579 boolean = bool580 581 582class ParameterDefinition:583 """The spec of an individual parameter defined in a RuleSet."""584 585 def __init__(586 self,587 name,588 parameter_type,589 documentation=None,590 builtIn=None,591 default=None,592 required=None,593 deprecated=None,594 ):595 self.name = name596 try:597 self.parameter_type = getattr(598 ParameterType, parameter_type.lower()599 ).value600 except AttributeError:601 raise EndpointResolutionError(602 msg=f"Unknown parameter type: {parameter_type}. "603 "A parameter must be of type string or boolean."604 )605 self.documentation = documentation606 self.builtin = builtIn607 self.default = default608 self.required = required609 self.deprecated = deprecated610 611 def validate_input(self, value):612 """Perform base validation on parameter input.613 614 :type value: Any615 :raises: EndpointParametersError616 """617 618 if not isinstance(value, self.parameter_type):619 raise EndpointResolutionError(620 msg=f"Value ({self.name}) is the wrong "621 f"type. Must be {self.parameter_type}."622 )623 if self.deprecated is not None:624 depr_str = f"{self.name} has been deprecated."625 msg = self.deprecated.get("message")626 since = self.deprecated.get("since")627 if msg:628 depr_str += f"\n{msg}"629 if since:630 depr_str += f"\nDeprecated since {since}."631 logger.info(depr_str)632 633 return None634 635 def process_input(self, value):636 """Process input against spec, applying default if value is None."""637 if value is None:638 if self.default is not None:639 return self.default640 if self.required:641 raise EndpointResolutionError(642 f"Cannot find value for required parameter {self.name}"643 )644 # in all other cases, the parameter will keep the value None645 else:646 self.validate_input(value)647 return value648 649 650class RuleSet:651 """Collection of rules to derive a routable service endpoint."""652 653 def __init__(654 self, version, parameters, rules, partitions, documentation=None655 ):656 self.version = version657 self.parameters = self._ingest_parameter_spec(parameters)658 self.rules = [RuleCreator.create(**rule) for rule in rules]659 self.rule_lib = RuleSetStandardLibrary(partitions)660 self.documentation = documentation661 662 def _ingest_parameter_spec(self, parameters):663 return {664 name: ParameterDefinition(665 name,666 spec["type"],667 spec.get("documentation"),668 spec.get("builtIn"),669 spec.get("default"),670 spec.get("required"),671 spec.get("deprecated"),672 )673 for name, spec in parameters.items()674 }675 676 def process_input_parameters(self, input_params):677 """Process each input parameter against its spec.678 679 :type input_params: dict680 """681 for name, spec in self.parameters.items():682 value = spec.process_input(input_params.get(name))683 if value is not None:684 input_params[name] = value685 return None686 687 def evaluate(self, input_parameters):688 """Evaluate input parameters against rules returning first match.689 690 :type input_parameters: dict691 """692 self.process_input_parameters(input_parameters)693 for rule in self.rules:694 evaluation = rule.evaluate(input_parameters.copy(), self.rule_lib)695 if evaluation is not None:696 return evaluation697 return None698 699 700class EndpointProvider:701 """Derives endpoints from a RuleSet for given input parameters."""702 703 def __init__(self, ruleset_data, partition_data):704 self.ruleset = RuleSet(**ruleset_data, partitions=partition_data)705 706 @lru_cache_weakref(maxsize=CACHE_SIZE)707 def resolve_endpoint(self, **input_parameters):708 """Match input parameters to a rule.709 710 :type input_parameters: dict711 :rtype: RuleSetEndpoint712 """713 params_for_error = input_parameters.copy()714 endpoint = self.ruleset.evaluate(input_parameters)715 if endpoint is None:716 param_string = "\n".join(717 [f"{key}: {value}" for key, value in params_for_error.items()]718 )719 raise EndpointResolutionError(720 msg=f"No endpoint found for parameters:\n{param_string}"721 )722 return endpoint723 