...

Text file src/github.com/emissary-ingress/emissary/v3/python/ambassador/ir/irhttpmapping.py

Documentation: github.com/emissary-ingress/emissary/v3/python/ambassador/ir

     1import hashlib
     2from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Type, Union
     3
     4from ambassador.utils import ParsedService as Service
     5from ambassador.utils import RichStatus
     6
     7from ..config import Config
     8from .irbasemapping import IRBaseMapping, normalize_service_name
     9from .irbasemappinggroup import IRBaseMappingGroup
    10from .ircors import IRCORS
    11from .irerrorresponse import IRErrorResponse
    12from .irhttpmappinggroup import IRHTTPMappingGroup
    13from .irretrypolicy import IRRetryPolicy
    14
    15if TYPE_CHECKING:
    16    from .ir import IR  # pragma: no cover
    17
    18
    19# Kind of cheating here so that it's easy to json-serialize key-value pairs (including with regex)
    20class KeyValueDecorator(dict):
    21    def __init__(
    22        self, name: str, value: Optional[str] = None, regex: Optional[bool] = False
    23    ) -> None:
    24        super().__init__()
    25        self.name = name
    26        self.value = value
    27        self.regex = regex
    28
    29    def __getattr__(self, key: str) -> Any:
    30        return self[key]
    31
    32    def __setattr__(self, key: str, value: Any) -> None:
    33        self[key] = value
    34
    35    def _get_value(self) -> str:
    36        return self.value or "*"
    37
    38    def length(self) -> int:
    39        return len(self.name) + len(self._get_value()) + (1 if self.regex else 0)
    40
    41    def key(self) -> str:
    42        return self.name + "-" + self._get_value()
    43
    44
    45class IRHTTPMapping(IRBaseMapping):
    46    prefix: str
    47    headers: List[KeyValueDecorator]
    48    add_request_headers: Dict[str, str]
    49    add_response_headers: Dict[str, str]
    50    method: Optional[str]
    51    service: str
    52    group_id: str
    53    route_weight: List[Union[str, int]]
    54    cors: IRCORS
    55    retry_policy: IRRetryPolicy
    56    error_response_overrides: Optional[IRErrorResponse]
    57    query_parameters: List[KeyValueDecorator]
    58    regex_rewrite: Dict[str, str]
    59
    60    # Keys that are present in AllowedKeys are allowed to be set from kwargs.
    61    # If the value is True, we'll look for a default in the Ambassador module
    62    # if the key is missing. If the value is False, a missing key will simply
    63    # be unset.
    64    #
    65    # Do not include any named parameters (like 'precedence' or 'rewrite').
    66    #
    67    # Any key here will be copied into the mapping. Keys where the only
    68    # processing is to set something else (like 'host' and 'method', whose
    69    # which only need to set the ':authority' and ':method' headers) must
    70    # _not_ be included here. Keys that need to be copied _and_ have special
    71    # processing (like 'service', which must be copied and used to wrangle
    72    # Linkerd headers) _do_ need to be included.
    73
    74    AllowedKeys: ClassVar[Dict[str, bool]] = {
    75        "add_linkerd_headers": False,
    76        # Do not include add_request_headers and add_response_headers
    77        "auto_host_rewrite": False,
    78        "bypass_auth": False,
    79        "auth_context_extensions": False,
    80        "bypass_error_response_overrides": False,
    81        "case_sensitive": False,
    82        "circuit_breakers": False,
    83        "cluster_idle_timeout_ms": False,
    84        "cluster_max_connection_lifetime_ms": False,
    85        # Do not include cluster_tag
    86        "connect_timeout_ms": False,
    87        "cors": False,
    88        "docs": False,
    89        "dns_type": False,
    90        "enable_ipv4": False,
    91        "enable_ipv6": False,
    92        "error_response_overrides": False,
    93        "grpc": False,
    94        # Do not include headers
    95        # Do not include host
    96        # Do not include hostname
    97        "health_checks": False,
    98        "host_redirect": False,
    99        "host_regex": False,
   100        "host_rewrite": False,
   101        "idle_timeout_ms": False,
   102        "keepalive": False,
   103        "labels": False,  # Not supported in v0; requires v1+; handled in setup
   104        "load_balancer": False,
   105        "metadata_labels": False,
   106        # Do not include method
   107        "method_regex": False,
   108        "path_redirect": False,
   109        "prefix_redirect": False,
   110        "regex_redirect": False,
   111        "redirect_response_code": False,
   112        # Do not include precedence
   113        "prefix": False,
   114        "prefix_exact": False,
   115        "prefix_regex": False,
   116        "priority": False,
   117        "rate_limits": False,  # Only supported in v0; replaced by "labels" in v1; handled in setup
   118        # Do not include regex_headers
   119        "remove_request_headers": True,
   120        "remove_response_headers": True,
   121        "resolver": False,
   122        "respect_dns_ttl": False,
   123        "retry_policy": False,
   124        # Do not include rewrite
   125        "service": False,  # See notes above
   126        "shadow": False,
   127        "stats_name": True,
   128        "timeout_ms": False,
   129        "tls": False,
   130        "use_websocket": False,
   131        "allow_upgrade": False,
   132        "weight": False,
   133        # Include the serialization, too.
   134        "serialization": False,
   135    }
   136
   137    def __init__(
   138        self,
   139        ir: "IR",
   140        aconf: Config,
   141        rkey: str,  # REQUIRED
   142        name: str,  # REQUIRED
   143        location: str,  # REQUIRED
   144        service: str,  # REQUIRED
   145        namespace: Optional[str] = None,
   146        metadata_labels: Optional[Dict[str, str]] = None,
   147        kind: str = "IRHTTPMapping",
   148        apiVersion: str = "getambassador.io/v3alpha1",  # Not a typo! See below.
   149        precedence: int = 0,
   150        rewrite: str = "/",
   151        cluster_tag: Optional[str] = None,
   152        **kwargs,
   153    ) -> None:
   154        # OK, this is a bit of a pain. We want to preserve the name and rkey and
   155        # such here, unlike most kinds of IRResource, so we shallow copy the keys
   156        # we're going to allow from the incoming kwargs.
   157        #
   158        # NOTE WELL: things that we explicitly process below should _not_ be listed in
   159        # AllowedKeys. The point of AllowedKeys is this loop below.
   160
   161        new_args = {}
   162
   163        # When we look up defaults, use lookup class "httpmapping"... and yeah, we need the
   164        # IR, too.
   165        self.default_class = "httpmapping"
   166        self.ir = ir
   167
   168        for key, check_defaults in IRHTTPMapping.AllowedKeys.items():
   169            # Do we have a keyword arg for this key?
   170            if key in kwargs:
   171                # Yes, it wins.
   172                value = kwargs[key]
   173                new_args[key] = value
   174            elif check_defaults:
   175                # No value in kwargs, but we're allowed to check defaults for it.
   176                value = self.lookup_default(key)
   177
   178                if value is not None:
   179                    new_args[key] = value
   180
   181        # add_linkerd_headers is special, because we support setting it as a default
   182        # in the bare Ambassador module. We should really toss this and use the defaults
   183        # mechanism, but not for 1.4.3.
   184
   185        if "add_linkerd_headers" not in new_args:
   186            # They didn't set it explicitly, so check for the older way.
   187            add_linkerd_headers = self.ir.ambassador_module.get("add_linkerd_headers", None)
   188
   189            if add_linkerd_headers != None:
   190                new_args["add_linkerd_headers"] = add_linkerd_headers
   191
   192        # OK. On to set up the headers (since we need them to compute our group ID).
   193        hdrs = []
   194        query_parameters = []
   195        regex_rewrite = kwargs.get("regex_rewrite", {})
   196
   197        # Start by assuming that nothing in our arguments mentions hosts (so no host and no host_regex).
   198        host = None
   199        host_regex = False
   200
   201        # Also start self.host as unspecified.
   202        self.host = None
   203
   204        # OK. Start by looking for a :authority header match.
   205        if "headers" in kwargs:
   206            for name, value in kwargs.get("headers", {}).items():
   207                if value is True:
   208                    hdrs.append(KeyValueDecorator(name))
   209                else:
   210                    # An exact match on the :authority header is special -- treat it like
   211                    # they set the "host" element (but note that we'll allow the actual
   212                    # "host" element to override it later).
   213                    if name.lower() == ":authority":
   214                        # This is an _exact_ match, so it mustn't contain a "*" -- that's illegal in the DNS.
   215                        if "*" in value:
   216                            # We can't call self.post_error() yet, because we're not initialized yet. So we cheat a bit
   217                            # and defer the error for later.
   218                            new_args[
   219                                "_deferred_error"
   220                            ] = f":authority exact-match '{value}' contains *, which cannot match anything."
   221                            ir.logger.debug(
   222                                "IRHTTPMapping %s: self.host contains * (%s, :authority)",
   223                                name,
   224                                value,
   225                            )
   226                        else:
   227                            # No globs, just save it. (We'll end up using it as a glob later, in the Envoy
   228                            # config part of the world, but that's OK -- a glob with no "*" in it will always
   229                            # match only itself.)
   230                            host = value
   231                            ir.logger.debug(
   232                                "IRHTTPMapping %s: self.host == %s (:authority)", name, self.host
   233                            )
   234                            # DO NOT save the ':authority' match here -- we'll pick it up after we've checked
   235                            # for hostname, too.
   236                    else:
   237                        # It's not an :authority match, so we're good.
   238                        hdrs.append(KeyValueDecorator(name, value))
   239
   240        if "regex_headers" in kwargs:
   241            # DON'T do anything special with a regex :authority match: we can't
   242            # do host-based filtering within the IR for it anyway.
   243            for name, value in kwargs.get("regex_headers", {}).items():
   244                hdrs.append(KeyValueDecorator(name, value, regex=True))
   245
   246        if "host" in kwargs:
   247            # It's deliberate that we'll allow kwargs['host'] to silently override an exact :authority
   248            # header match.
   249            host = kwargs["host"]
   250            host_regex = kwargs.get("host_regex", False)
   251
   252            # If it's not a regex, it's an exact match -- make sure it doesn't contain a '*'.
   253            if not host_regex:
   254                if "*" in host:
   255                    # We can't call self.post_error() yet, because we're not initialized yet. So we cheat a bit
   256                    # and defer the error for later.
   257                    new_args[
   258                        "_deferred_error"
   259                    ] = f"host exact-match {host} contains *, which cannot match anything."
   260                    ir.logger.debug("IRHTTPMapping %s: self.host contains * (%s, host)", name, host)
   261                else:
   262                    ir.logger.debug("IRHTTPMapping %s: self.host == %s (host)", name, self.host)
   263
   264        # Finally, check for 'hostname'.
   265        if "hostname" in kwargs:
   266            # It's deliberate that we allow kwargs['hostname'] to override anything else -- even a regex host.
   267            # Yell about it, though.
   268            if host:
   269                ir.logger.warning(
   270                    "Mapping %s in namespace %s: both host and hostname are set, using hostname and ignoring host",
   271                    name,
   272                    namespace,
   273                )
   274
   275            # No need to be so careful about "*" here, since hostname is defined to be a glob.
   276            host = kwargs["hostname"]
   277            host_regex = False
   278            ir.logger.debug("IRHTTPMapping %s: self.host gl~ %s (hostname)", name, self.host)
   279
   280        # If we have a host, include a ":authority" match. We're treating this as if it were
   281        # an exact match, but that's because the ":authority" match is handling specially by
   282        # Envoy.
   283        if host:
   284            hdrs.append(KeyValueDecorator(":authority", host, host_regex))
   285
   286            # Finally, if our host isn't a regex, save it in self.host.
   287            if not host_regex:
   288                self.host = host
   289
   290        if "method" in kwargs:
   291            hdrs.append(
   292                KeyValueDecorator(":method", kwargs["method"], kwargs.get("method_regex", False))
   293            )
   294
   295        if "use_websocket" in new_args:
   296            allow_upgrade = new_args.setdefault("allow_upgrade", [])
   297            if "websocket" not in allow_upgrade:
   298                allow_upgrade.append("websocket")
   299            del new_args["use_websocket"]
   300
   301        # Next up: figure out what headers we need to add to each request. Again, if the key
   302        # is present in kwargs, the kwargs value wins -- this is important to allow explicitly
   303        # setting a value of `{}` to override a default!
   304
   305        add_request_hdrs: dict
   306        add_response_hdrs: dict
   307
   308        if "add_request_headers" in kwargs:
   309            add_request_hdrs = kwargs["add_request_headers"]
   310        else:
   311            add_request_hdrs = self.lookup_default("add_request_headers", {})
   312
   313        if "add_response_headers" in kwargs:
   314            add_response_hdrs = kwargs["add_response_headers"]
   315        else:
   316            add_response_hdrs = self.lookup_default("add_response_headers", {})
   317
   318        # Remember that we may need to add the Linkerd headers, too.
   319        add_linkerd_headers = new_args.get("add_linkerd_headers", False)
   320
   321        # XXX The resolver lookup code is duplicated from IRBaseMapping.setup --
   322        # needs to be fixed after 1.6.1.
   323        resolver_name = kwargs.get("resolver") or self.ir.ambassador_module.get(
   324            "resolver", "kubernetes-service"
   325        )
   326
   327        assert resolver_name  # for mypy -- resolver_name cannot be None at this point
   328        resolver = self.ir.get_resolver(resolver_name)
   329
   330        if resolver:
   331            resolver_kind = resolver.kind
   332        else:
   333            # In IRBaseMapping.setup, we post an error if the resolver is unknown.
   334            # Here, we just don't bother; we're only using it for service
   335            # qualification.
   336            resolver_kind = "KubernetesBogusResolver"
   337
   338        service = normalize_service_name(ir, service, namespace, resolver_kind, rkey=rkey)
   339        self.ir.logger.debug(f"Mapping {name} service qualified to {repr(service)}")
   340
   341        svc = Service(ir.logger, service)
   342
   343        if add_linkerd_headers:
   344            add_request_hdrs["l5d-dst-override"] = svc.hostname_port
   345
   346        # XXX BRUTAL HACK HERE:
   347        # If we _don't_ have an origination context, but our IR has an agent_origination_ctx,
   348        # force TLS origination because it's the agent. I know, I know. It's a hack.
   349        if ("tls" not in new_args) and ir.agent_origination_ctx:
   350            ir.logger.debug(
   351                f"Mapping {name}: Agent forcing origination TLS context to {ir.agent_origination_ctx.name}"
   352            )
   353            new_args["tls"] = ir.agent_origination_ctx.name
   354
   355        if "query_parameters" in kwargs:
   356            for pname, pvalue in kwargs.get("query_parameters", {}).items():
   357                if pvalue is True:
   358                    query_parameters.append(KeyValueDecorator(pname))
   359                else:
   360                    query_parameters.append(KeyValueDecorator(pname, pvalue))
   361
   362        if "regex_query_parameters" in kwargs:
   363            for pname, pvalue in kwargs.get("regex_query_parameters", {}).items():
   364                query_parameters.append(KeyValueDecorator(pname, pvalue, regex=True))
   365
   366        if "regex_rewrite" in kwargs:
   367            if rewrite and rewrite != "/":
   368                self.ir.aconf.post_notice(
   369                    "Cannot specify both rewrite and regex_rewrite: using regex_rewrite and ignoring rewrite"
   370                )
   371            rewrite = ""
   372            rewrite_items = kwargs.get("regex_rewrite", {})
   373            regex_rewrite = {
   374                "pattern": rewrite_items.get("pattern", ""),
   375                "substitution": rewrite_items.get("substitution", ""),
   376            }
   377
   378        # ...and then init the superclass.
   379        super().__init__(
   380            ir=ir,
   381            aconf=aconf,
   382            rkey=rkey,
   383            location=location,
   384            service=service,
   385            kind=kind,
   386            name=name,
   387            namespace=namespace,
   388            metadata_labels=metadata_labels,
   389            apiVersion=apiVersion,
   390            headers=hdrs,
   391            add_request_headers=add_request_hdrs,
   392            add_response_headers=add_response_hdrs,
   393            precedence=precedence,
   394            rewrite=rewrite,
   395            cluster_tag=cluster_tag,
   396            query_parameters=query_parameters,
   397            regex_rewrite=regex_rewrite,
   398            **new_args,
   399        )
   400
   401        if "outlier_detection" in kwargs:
   402            self.post_error(RichStatus.fromError("outlier_detection is not supported"))
   403
   404    @staticmethod
   405    def group_class() -> Type[IRBaseMappingGroup]:
   406        return IRHTTPMappingGroup
   407
   408    def _enforce_mutual_exclusion(self, preferred, other):
   409        if preferred in self and other in self:
   410            self.ir.aconf.post_error(
   411                f"Cannot specify both {preferred} and {other}. Using {preferred} and ignoring {other}.",
   412                resource=self,
   413            )
   414            del self[other]
   415
   416    def setup(self, ir: "IR", aconf: Config) -> bool:
   417        # First things first: handle any deferred error.
   418        _deferred_error = self.get("_deferred_error")
   419        if _deferred_error:
   420            self.post_error(_deferred_error)
   421            return False
   422
   423        if not super().setup(ir, aconf):
   424            return False
   425
   426        # If we have CORS stuff, normalize it.
   427        if "cors" in self:
   428            self.cors = IRCORS(ir=ir, aconf=aconf, location=self.location, **self.cors)
   429
   430            if self.cors:
   431                self.cors.referenced_by(self)
   432            else:
   433                return False
   434
   435        # If we have RETRY_POLICY stuff, normalize it.
   436        if "retry_policy" in self:
   437            self.retry_policy = IRRetryPolicy(
   438                ir=ir, aconf=aconf, location=self.location, **self.retry_policy
   439            )
   440
   441            if self.retry_policy:
   442                self.retry_policy.referenced_by(self)
   443            else:
   444                return False
   445
   446        # If we have error response overrides, generate an IR for that too.
   447        if "error_response_overrides" in self:
   448            self.error_response_overrides = IRErrorResponse(
   449                self.ir, aconf, self.get("error_response_overrides", None), location=self.location
   450            )
   451            # if self.error_response_overrides.setup(self.ir, aconf):
   452            if self.error_response_overrides:
   453                self.error_response_overrides.referenced_by(self)
   454            else:
   455                return False
   456
   457        if self.get("load_balancer", None) is not None:
   458            if not self.validate_load_balancer(self["load_balancer"]):
   459                self.post_error(
   460                    "Invalid load_balancer specified: {}, invalidating mapping".format(
   461                        self["load_balancer"]
   462                    )
   463                )
   464                return False
   465
   466        # All three redirect fields are mutually exclusive.
   467        #
   468        # Prefer path_redirect over the other two. If only prefix_redirect and
   469        # regex_redirect are set, prefer prefix_redirect. There's no exact
   470        # reason for this, only to arbitrarily prefer "less fancy" features.
   471        self._enforce_mutual_exclusion("path_redirect", "prefix_redirect")
   472        self._enforce_mutual_exclusion("path_redirect", "regex_redirect")
   473        self._enforce_mutual_exclusion("prefix_redirect", "regex_redirect")
   474
   475        ir.logger.debug(
   476            "Mapping %s: setup OK: host %s hostname %s regex %s",
   477            self.name,
   478            self.get("host"),
   479            self.get("hostname"),
   480            self.get("host_regex"),
   481        )
   482
   483        return True
   484
   485    @staticmethod
   486    def validate_load_balancer(load_balancer) -> bool:
   487        lb_policy = load_balancer.get("policy", None)
   488
   489        is_valid = False
   490        if lb_policy in ["round_robin", "least_request"]:
   491            if len(load_balancer) == 1:
   492                is_valid = True
   493        elif lb_policy in ["ring_hash", "maglev"]:
   494            if len(load_balancer) == 2:
   495                if "cookie" in load_balancer:
   496                    cookie = load_balancer.get("cookie")
   497                    if "name" in cookie:
   498                        is_valid = True
   499                elif "header" in load_balancer:
   500                    is_valid = True
   501                elif "source_ip" in load_balancer:
   502                    is_valid = True
   503
   504        return is_valid
   505
   506    def _group_id(self) -> str:
   507        # Yes, we're using a cryptographic hash here. Cope. [ :) ]
   508
   509        h = hashlib.new("sha1")
   510
   511        # This is an HTTP mapping.
   512        h.update("HTTP-".encode("utf-8"))
   513
   514        # method first, but of course method might be None. For calculating the
   515        # group_id, 'method' defaults to 'GET' (for historical reasons).
   516
   517        method = self.get("method") or "GET"
   518        h.update(method.encode("utf-8"))
   519        h.update(self.prefix.encode("utf-8"))
   520
   521        for hdr in self.headers:
   522            h.update(hdr.name.encode("utf-8"))
   523
   524            if hdr.value is not None:
   525                h.update(hdr.value.encode("utf-8"))
   526
   527        for query_parameter in self.query_parameters:
   528            h.update(query_parameter.name.encode("utf-8"))
   529
   530            if query_parameter.value is not None:
   531                h.update(query_parameter.value.encode("utf-8"))
   532
   533        if self.precedence != 0:
   534            h.update(str(self.precedence).encode("utf-8"))
   535
   536        return h.hexdigest()
   537
   538    def _route_weight(self) -> List[Union[str, int]]:
   539        len_headers = 0
   540        len_query_parameters = 0
   541
   542        for hdr in self.headers:
   543            len_headers += hdr.length()
   544
   545        for query_parameter in self.query_parameters:
   546            len_query_parameters += query_parameter.length()
   547
   548        # For calculating the route weight, 'method' defaults to '*' (for historical reasons).
   549
   550        weight = [
   551            self.precedence,
   552            len(self.prefix),
   553            len_headers,
   554            len_query_parameters,
   555            self.prefix,
   556            self.get("method", "GET"),
   557        ]
   558        weight += [hdr.key() for hdr in self.headers]
   559        weight += [query_parameter.key() for query_parameter in self.query_parameters]
   560
   561        return weight
   562
   563    def summarize_errors(self) -> str:
   564        errors = self.ir.aconf.errors.get(self.rkey, [])
   565        errstr = "(no errors)"
   566
   567        if errors:
   568            errstr = errors[0].get("error") or "unknown error?"
   569
   570            if len(errors) > 1:
   571                errstr += " (and more)"
   572
   573        return errstr
   574
   575    def status(self) -> Dict[str, str]:
   576        if not self.is_active():
   577            return {"state": "Inactive", "reason": self.summarize_errors()}
   578        else:
   579            return {"state": "Running"}

View as plain text