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