...

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

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

     1from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Tuple
     2from typing import cast as typecast
     3
     4from ambassador.utils import RichStatus
     5
     6from ..config import Config
     7from .irbasemapping import IRBaseMapping
     8from .irbasemappinggroup import IRBaseMappingGroup
     9from .ircluster import IRCluster
    10from .irresource import IRResource
    11
    12if TYPE_CHECKING:
    13    from .ir import IR  # pragma: no cover
    14
    15
    16########
    17## IRHTTPMappingGroup is a collection of Mappings. We'll use it to build Envoy routes later,
    18## so the group itself ends up with some of the group-wide attributes of its Mappings.
    19
    20
    21class IRHTTPMappingGroup(IRBaseMappingGroup):
    22    host_redirect: Optional[IRBaseMapping]
    23    shadow: List[IRBaseMapping]
    24    rewrite: str
    25    add_request_headers: Dict[str, str]
    26    add_response_headers: Dict[str, str]
    27
    28    CoreMappingKeys: ClassVar[Dict[str, bool]] = {
    29        "bypass_auth": True,
    30        "bypass_error_response_overrides": True,
    31        "circuit_breakers": True,
    32        "cluster_timeout_ms": True,
    33        "connect_timeout_ms": True,
    34        "cluster_idle_timeout_ms": True,
    35        "cluster_max_connection_lifetime_ms": True,
    36        "group_id": True,
    37        "headers": True,
    38        # 'host_rewrite': True,
    39        # 'idle_timeout_ms': True,
    40        "keepalive": True,
    41        # 'labels' doesn't appear in the TransparentKeys list for IRMapping, but it's still
    42        # a CoreMappingKey -- if it appears, it can't have multiple values within an IRHTTPMappingGroup.
    43        "labels": True,
    44        "load_balancer": True,
    45        # 'metadata_labels' will get flattened by merging. The group gets all the labels that all its
    46        # Mappings have.
    47        "method": True,
    48        "prefix": True,
    49        "prefix_regex": True,
    50        "prefix_exact": True,
    51        # 'rewrite': True,
    52        # 'timeout_ms': True
    53    }
    54
    55    # We don't flatten cluster_key and stats_name because the whole point of those
    56    # two is that you're asking for something special with stats. Note that we also
    57    # don't do collision checking specially for the stats_name: if you ask for the
    58    # same stats_name in two unrelated mappings, on your own head be it.
    59
    60    DoNotFlattenKeys: ClassVar[Dict[str, bool]] = dict(CoreMappingKeys)
    61    DoNotFlattenKeys.update(
    62        {
    63            "add_request_headers": True,  # do this manually.
    64            "add_response_headers": True,  # do this manually.
    65            "cluster": True,
    66            "cluster_key": True,  # See above about stats.
    67            "kind": True,
    68            "location": True,
    69            "name": True,
    70            "resolver": True,  # can't flatten the resolver...
    71            "rkey": True,
    72            "route_weight": True,
    73            "service": True,
    74            "stats_name": True,  # See above about stats.
    75            "weight": True,
    76        }
    77    )
    78
    79    @staticmethod
    80    def helper_mappings(res: IRResource, k: str) -> Tuple[str, List[dict]]:
    81        return k, list(
    82            reversed(sorted([x.as_dict() for x in res.mappings], key=lambda x: x["route_weight"]))
    83        )
    84
    85    @staticmethod
    86    def helper_shadows(res: IRResource, k: str) -> Tuple[str, List[dict]]:
    87        return k, list([x.as_dict() for x in res[k]])
    88
    89    def __init__(
    90        self,
    91        ir: "IR",
    92        aconf: Config,
    93        location: str,
    94        mapping: IRBaseMapping,
    95        rkey: str = "ir.mappinggroup",
    96        kind: str = "IRHTTPMappingGroup",
    97        name: str = "ir.mappinggroup",
    98        **kwargs,
    99    ) -> None:
   100        # print("IRHTTPMappingGroup __init__ (%s %s %s)" % (kind, name, kwargs))
   101        del rkey  # silence unused-variable warning
   102
   103        if "host_redirect" in kwargs:
   104            raise Exception(
   105                "IRHTTPMappingGroup cannot accept a host_redirect as a keyword argument"
   106            )
   107
   108        if "path_redirect" in kwargs:
   109            raise Exception(
   110                "IRHTTPMappingGroup cannot accept a path_redirect as a keyword argument"
   111            )
   112
   113        if "prefix_redirect" in kwargs:
   114            raise Exception(
   115                "IRHTTPMappingGroup cannot accept a prefix_redirect as a keyword argument"
   116            )
   117
   118        if "regex_redirect" in kwargs:
   119            raise Exception(
   120                "IRHTTPMappingGroup cannot accept a regex_redirect as a keyword argument"
   121            )
   122
   123        if ("shadow" in kwargs) or ("shadows" in kwargs):
   124            raise Exception(
   125                "IRHTTPMappingGroup cannot accept shadow or shadows as a keyword argument"
   126            )
   127
   128        super().__init__(
   129            ir=ir, aconf=aconf, rkey=mapping.rkey, location=location, kind=kind, name=name, **kwargs
   130        )
   131
   132        self.host_redirect = None
   133        self.shadows: List[IRBaseMapping] = []  # XXX This should really be IRHTTPMapping, no?
   134
   135        self.add_dict_helper("mappings", IRHTTPMappingGroup.helper_mappings)
   136        self.add_dict_helper("shadows", IRHTTPMappingGroup.helper_shadows)
   137
   138        # Time to lift a bunch of core stuff from the first mapping up into the
   139        # group.
   140
   141        if ("group_weight" not in self) and ("route_weight" in mapping):
   142            self.group_weight = mapping.route_weight
   143
   144        for k in IRHTTPMappingGroup.CoreMappingKeys:
   145            if (k not in self) and (k in mapping):
   146                self[k] = mapping[k]
   147
   148        self.add_mapping(aconf, mapping)
   149
   150        # self.add_request_headers = {}
   151        # self.add_response_headers = {}
   152        # self.labels = {}
   153
   154    def add_mapping(self, aconf: Config, mapping: IRBaseMapping) -> None:
   155        mismatches = []
   156
   157        for k in IRHTTPMappingGroup.CoreMappingKeys:
   158            if (k in mapping) and ((k not in self) or (mapping[k] != self[k])):
   159                mismatches.append((k, mapping[k], self.get(k, "-unset-")))
   160
   161        if mismatches:
   162            self.post_error(
   163                "cannot accept new mapping %s with mismatched %s."
   164                "Please verify field is set with the same value in all related mappings."
   165                "Example: When canary is configured, related mappings should have same fields and values"
   166                % (mapping.name, ", ".join(["%s: %s != %s" % (x, y, z) for x, y, z in mismatches]))
   167            )
   168            return
   169
   170        # self.ir.logger.debug("%s: add mapping %s" % (self, mapping.as_json()))
   171
   172        # Per the schema, host_redirect and shadow are Booleans. They won't be _saved_ as
   173        # Booleans, though: instead we just save the Mapping that they're a part of.
   174        host_redirect = mapping.get("host_redirect", False)
   175        shadow = mapping.get("shadow", False)
   176
   177        # First things first: if both shadow and host_redirect are set in this Mapping,
   178        # we're going to let shadow win. Kill the host_redirect part.
   179
   180        if shadow and host_redirect:
   181            errstr = "At most one of host_redirect and shadow may be set; ignoring host_redirect"
   182            aconf.post_error(RichStatus.fromError(errstr), resource=mapping)
   183
   184            mapping.pop("host_redirect", None)
   185            mapping.pop("path_redirect", None)
   186            mapping.pop("prefix_redirect", None)
   187            mapping.pop("regex_redirect", None)
   188
   189        # OK. Is this a shadow Mapping?
   190        if shadow:
   191            # Yup. Make sure that we don't have multiple shadows.
   192            if self.shadows:
   193                errstr = "cannot accept %s as second shadow after %s" % (
   194                    mapping.name,
   195                    self.shadows[0].name,
   196                )
   197                aconf.post_error(RichStatus.fromError(errstr), resource=self)
   198            else:
   199                # All good. Save it.
   200                self.shadows.append(mapping)
   201        elif host_redirect:
   202            # Not a shadow, but a host_redirect. Make sure we don't have multiples of
   203            # those either.
   204
   205            if self.host_redirect:
   206                errstr = "cannot accept %s as second host_redirect after %s" % (
   207                    mapping.name,
   208                    typecast(IRBaseMapping, self.host_redirect).name,
   209                )
   210                aconf.post_error(RichStatus.fromError(errstr), resource=self)
   211            elif len(self.mappings) > 0:
   212                errstr = (
   213                    "cannot accept %s with host_redirect after mappings without host_redirect (eg %s)"
   214                    % (mapping.name, self.mappings[0].name)
   215                )
   216                aconf.post_error(RichStatus.fromError(errstr), resource=self)
   217            else:
   218                # All good. Save it.
   219                self.host_redirect = mapping
   220        else:
   221            # Neither shadow nor host_redirect are set in the Mapping.
   222            #
   223            # XXX At the moment, we do not do the right thing with the case where some Mappings
   224            # in a group have host_redirect and some do not, so make sure that that can't happen.
   225
   226            if self.host_redirect:
   227                aconf.post_error(
   228                    "cannot accept %s without host_redirect after %s with host_redirect"
   229                    % (mapping.name, typecast(IRBaseMapping, self.host_redirect).name)
   230                )
   231            else:
   232                # All good. Save this mapping.
   233                self.mappings.append(mapping)
   234
   235                if mapping.route_weight > self.group_weight:
   236                    self.group_weight = mapping.route_weight
   237
   238        self.referenced_by(mapping)
   239
   240        # self.ir.logger.debug("%s: group now %s" % (self, self.as_json()))
   241
   242    def add_cluster_for_mapping(
   243        self, mapping: IRBaseMapping, marker: Optional[str] = None
   244    ) -> IRCluster:
   245        # Find or create the cluster for this Mapping...
   246
   247        self.ir.logger.debug(
   248            f"IRHTTPMappingGroup: {self.group_id} adding cluster for Mapping {mapping.name} (key {mapping.cluster_key})"
   249        )
   250
   251        cluster: Optional[IRCluster] = None
   252
   253        if mapping.cluster_key:
   254            # Aha. Is our cluster already in the cache?
   255            cached_cluster = self.ir.cache_fetch(mapping.cluster_key)
   256
   257            if cached_cluster is not None:
   258                # We know a priori that anything in the cache under a cluster key must be
   259                # an IRCluster, but let's assert that rather than casting.
   260                assert isinstance(cached_cluster, IRCluster)
   261                cluster = cached_cluster
   262
   263                self.ir.logger.debug(
   264                    f"IRHTTPMappingGroup: got Cluster from cache for {mapping.cluster_key}"
   265                )
   266
   267        if not cluster:
   268            # OK, we have to actually do some work.
   269            self.ir.logger.debug(f"IRHTTPMappingGroup: synthesizing Cluster for {mapping.name}")
   270            cluster = IRCluster(
   271                ir=self.ir,
   272                aconf=self.ir.aconf,
   273                parent_ir_resource=mapping,
   274                location=mapping.location,
   275                service=mapping.service,
   276                resolver=mapping.resolver,
   277                ctx_name=mapping.get("tls", None),
   278                dns_type=mapping.get("dns_type", "strict_dns"),
   279                health_checks=mapping.get("health_checks", None),
   280                host_rewrite=mapping.get("host_rewrite", False),
   281                enable_ipv4=mapping.get("enable_ipv4", None),
   282                enable_ipv6=mapping.get("enable_ipv6", None),
   283                grpc=mapping.get("grpc", False),
   284                load_balancer=mapping.get("load_balancer", None),
   285                keepalive=mapping.get("keepalive", None),
   286                connect_timeout_ms=mapping.get("connect_timeout_ms", 3000),
   287                cluster_idle_timeout_ms=mapping.get("cluster_idle_timeout_ms", None),
   288                cluster_max_connection_lifetime_ms=mapping.get(
   289                    "cluster_max_connection_lifetime_ms", None
   290                ),
   291                circuit_breakers=mapping.get("circuit_breakers", None),
   292                marker=marker,
   293                stats_name=mapping.get("stats_name"),
   294                respect_dns_ttl=mapping.get("respect_dns_ttl", False),
   295            )
   296
   297        # Make sure that the cluster is actually in our IR...
   298        stored = self.ir.add_cluster(cluster)
   299        stored.referenced_by(mapping)
   300
   301        # ...and then check if we just synthesized this cluster.
   302        if not mapping.cluster_key:
   303            # Yes. The mapping is already in the cache, but we need to cache the cluster...
   304            self.ir.cache_add(stored)
   305
   306            # ...and link the Group to the cluster.
   307            #
   308            # Right now, I'm going for maximum safety, which means a single chain linking
   309            # Mapping -> Group -> Cluster. That means that deleting a single Mapping deletes
   310            # the Group to which that Mapping is attached, which in turn deletes all the
   311            # Clusters for that Group.
   312            #
   313            # Performance might dictate linking Mapping -> Group and Mapping -> Cluster, so
   314            # that deleting a Mapping deletes the Group but only the single Cluster. Needs
   315            # testing.
   316
   317            self.ir.cache_link(self, stored)
   318
   319            # Finally, save the cluster's cache_key in this Mapping.
   320            mapping.cluster_key = stored.cache_key
   321
   322        # Finally, return the stored cluster. Done.
   323        self.ir.logger.debug(
   324            f"IRHTTPMappingGroup: %s returning cluster %s for Mapping %s",
   325            self.group_id,
   326            stored,
   327            mapping.name,
   328        )
   329        return stored
   330
   331    def finalize(self, ir: "IR", aconf: Config) -> List[IRCluster]:
   332        """
   333        Finalize a MappingGroup based on the attributes of its Mappings. Core elements get lifted into
   334        the Group so we can more easily build Envoy routes; host-redirect and shadow get handled, etc.
   335
   336        :param ir: the IR we're working from
   337        :param aconf: the Config we're working from
   338        :return: a list of the IRClusters this Group uses
   339        """
   340
   341        add_request_headers: Dict[str, Any] = {}
   342        add_response_headers: Dict[str, Any] = {}
   343        metadata_labels: Dict[str, str] = {}
   344
   345        self.ir.logger.debug(f"IRHTTPMappingGroup: finalize %s", self.group_id)
   346
   347        for mapping in sorted(self.mappings, key=lambda m: m.route_weight):
   348            # if verbose:
   349            #     self.ir.logger.debug("%s mapping %s" % (self, mapping.as_json()))
   350
   351            for k in mapping.keys():
   352                if (
   353                    k.startswith("_")
   354                    or mapping.skip_key(k)
   355                    or (k in IRHTTPMappingGroup.DoNotFlattenKeys)
   356                ):
   357                    # if verbose:
   358                    #     self.ir.logger.debug("%s: don't flatten %s" % (self, k))
   359                    continue
   360
   361                # if verbose:
   362                #     self.ir.logger.debug("%s: flatten %s" % (self, k))
   363
   364                self[k] = mapping[k]
   365
   366            add_request_headers.update(mapping.get("add_request_headers", {}))
   367            add_response_headers.update(mapping.get("add_response_headers", {}))
   368
   369            # Should we have higher weights win over lower if there are conflicts?
   370            # Should we disallow conflicts?
   371            metadata_labels.update(mapping.get("metadata_labels") or {})
   372
   373        if add_request_headers:
   374            self.add_request_headers = add_request_headers
   375        if add_response_headers:
   376            self.add_response_headers = add_response_headers
   377
   378        if metadata_labels:
   379            self.metadata_labels = metadata_labels
   380
   381        if self.get("load_balancer", None) is None:
   382            self["load_balancer"] = ir.ambassador_module.load_balancer
   383
   384        # if verbose:
   385        #     self.ir.logger.debug("%s after flattening %s" % (self, self.as_json()))
   386
   387        total_weight = 0.0
   388        unspecified_mappings = 0
   389
   390        # If no rewrite was given at all, default the rewrite to "/", so /, so e.g., if we map
   391        # /prefix1/ to the service service1, then http://ambassador.example.com/prefix1/foo/bar
   392        # would effectively be written to http://service1/foo/bar
   393        #
   394        # If they did give a rewrite, leave it alone so that the Envoy config can correctly
   395        # handle an empty rewrite as no rewriting at all.
   396
   397        if "rewrite" not in self:
   398            self.rewrite = "/"
   399
   400        # OK. Save some typing with local variables for default labels and our labels...
   401        labels: Dict[str, Any] = self.get("labels", None)
   402
   403        if self.get("keepalive", None) is None:
   404            keepalive_default = ir.ambassador_module.get("keepalive", None)
   405            if keepalive_default:
   406                self["keepalive"] = keepalive_default
   407
   408        if not labels:
   409            # No labels. Use the default label domain to see if we have some valid defaults.
   410            defaults = ir.ambassador_module.get_default_labels()
   411
   412            if defaults:
   413                domain = ir.ambassador_module.get_default_label_domain()
   414
   415                self.labels = {domain: [{"defaults": defaults}]}
   416        else:
   417            # Walk all the domains in our labels, and prepend the defaults, if any.
   418            # ir.logger.info("%s: labels %s" % (self.as_json(), labels))
   419
   420            for domain in labels.keys():
   421                defaults = ir.ambassador_module.get_default_labels(domain)
   422                ir.logger.debug("%s: defaults %s" % (domain, defaults))
   423
   424                if defaults:
   425                    ir.logger.debug("%s: labels %s" % (domain, labels[domain]))
   426
   427                    for label in labels[domain]:
   428                        ir.logger.debug("%s: label %s" % (domain, label))
   429
   430                        lkeys = label.keys()
   431                        if len(lkeys) > 1:
   432                            err = RichStatus.fromError(
   433                                "label has multiple entries (%s) instead of just one" % lkeys
   434                            )
   435                            aconf.post_error(err, self)
   436
   437                        lkey = list(lkeys)[0]
   438
   439                        if lkey.startswith("v0_ratelimit_"):
   440                            # Don't prepend defaults, as this was imported from a V0 rate_limit.
   441                            continue
   442
   443                        label[lkey] = defaults + label[lkey]
   444
   445        if self.shadows:
   446            # Only one shadow is supported right now.
   447            shadow = self.shadows[0]
   448
   449            # The shadow is an IRMapping. Save the cluster for it.
   450            shadow.cluster = self.add_cluster_for_mapping(shadow, marker="shadow")
   451
   452        # We don't need a cluster for host_redirect: it's just a name to redirect to.
   453
   454        redir = self.get("host_redirect", None)
   455
   456        if not redir:
   457            self.ir.logger.debug(
   458                f"IRHTTPMappingGroup: checking mapping clusters for %s", self.group_id
   459            )
   460
   461            for mapping in self.mappings:
   462                mapping.cluster = self.add_cluster_for_mapping(mapping, mapping.cluster_tag)
   463
   464            self.ir.logger.debug(f"IRHTTPMappingGroup: normalizing weights for %s", self.group_id)
   465
   466            if not self.normalize_weights_in_mappings():
   467                self.post_error(f"Could not normalize mapping weights, ignoring...")
   468                return []
   469
   470            return list([mapping.cluster for mapping in self.mappings])
   471        else:
   472            # Flatten the case_sensitive field for host_redirect if it exists
   473            if "case_sensitive" in redir:
   474                self["case_sensitive"] = redir["case_sensitive"]
   475
   476            return []

View as plain text