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