1import json
2import logging
3import sys
4import time
5
6import pytest
7import requests
8
9from tests.integration.utils import create_httpbin_mapping, install_ambassador
10from tests.kubeutils import apply_kube_artifacts
11from tests.manifests import httpbin_manifests
12from tests.runutils import run_and_assert
13
14logging.basicConfig(
15 level=logging.INFO,
16 format="%(asctime)s test %(levelname)s: %(message)s",
17 datefmt="%Y-%m-%d %H:%M:%S",
18)
19
20logger = logging.getLogger("ambassador")
21
22from ambassador import IR, Config
23from ambassador.envoy import EnvoyConfig
24from ambassador.fetch import ResourceFetcher
25from ambassador.utils import NullSecretHandler
26
27headerecho_manifests = """
28---
29apiVersion: v1
30kind: Service
31metadata:
32 name: headerecho
33spec:
34 type: ClusterIP
35 selector:
36 service: headerecho
37 ports:
38 - port: 80
39 targetPort: http
40---
41apiVersion: apps/v1
42kind: Deployment
43metadata:
44 name: headerecho
45spec:
46 replicas: 1
47 selector:
48 matchLabels:
49 service: headerecho
50 template:
51 metadata:
52 labels:
53 service: headerecho
54 spec:
55 containers:
56 - name: headerecho
57 # We should find a better home for this image.
58 image: johnesmet/simple-header-echo
59 ports:
60 - name: http
61 containerPort: 8080
62"""
63
64
65def create_headerecho_mapping(namespace):
66 headerecho_mapping = f"""
67---
68apiVersion: getambassador.io/v3alpha1
69kind: Mapping
70metadata:
71 name: headerecho-mapping
72 namespace: {namespace}
73spec:
74 hostname: "*"
75 prefix: /headerecho/
76 rewrite: /
77 service: headerecho
78"""
79
80 apply_kube_artifacts(namespace=namespace, artifacts=headerecho_mapping)
81
82
83def _ambassador_module_config():
84 return """
85---
86apiVersion: getambassador.io/v3alpha1
87kind: Module
88metadata:
89 name: ambassador
90 namespace: default
91spec:
92 config:
93"""
94
95
96def _ambassador_module_header_case_overrides(overrides, proper_case=False):
97 mod = _ambassador_module_config()
98 if len(overrides) == 0:
99 mod = (
100 mod
101 + """
102 header_case_overrides: []
103"""
104 )
105 return mod
106
107 mod = (
108 mod
109 + """
110 header_case_overrides:
111"""
112 )
113 for override in overrides:
114 mod = (
115 mod
116 + f"""
117 - {override}
118"""
119 )
120 # proper case isn't valid if header_case_overrides are set, but we do
121 # it here for tests that want to test that this is in fact invalid.
122 if proper_case:
123 mod = (
124 mod
125 + f"""
126 proper_case: true
127"""
128 )
129 return mod
130
131
132def _test_headercaseoverrides(yaml, expectations, expect_norules=False):
133 aconf = Config()
134
135 yaml = (
136 yaml
137 + """
138---
139apiVersion: getambassador.io/v3alpha1
140kind: Listener
141metadata:
142 name: ambassador-listener-8080
143 namespace: default
144spec:
145 port: 8080
146 protocol: HTTPS
147 securityModel: XFP
148 hostBinding:
149 namespace:
150 from: ALL
151
152---
153apiVersion: getambassador.io/v3alpha1
154kind: Mapping
155metadata:
156 name: httpbin-mapping
157 namespace: default
158spec:
159 service: httpbin
160 hostname: "*"
161 prefix: /httpbin/
162"""
163 )
164
165 fetcher = ResourceFetcher(logger, aconf)
166 fetcher.parse_yaml(yaml, k8s=True)
167
168 aconf.load_all(fetcher.sorted())
169
170 secret_handler = NullSecretHandler(logger, None, None, "0")
171
172 ir = IR(aconf, file_checker=lambda path: True, secret_handler=secret_handler)
173 assert ir
174
175 econf = EnvoyConfig.generate(ir)
176 assert econf, "could not create an econf"
177
178 found_module_rules = False
179 found_cluster_rules = False
180 conf = econf.as_dict()
181
182 for listener in conf["static_resources"]["listeners"]:
183 for filter_chain in listener["filter_chains"]:
184 for f in filter_chain["filters"]:
185 typed_config = f["typed_config"]
186 if "http_protocol_options" not in typed_config:
187 continue
188
189 http_protocol_options = typed_config["http_protocol_options"]
190 if expect_norules:
191 assert (
192 "header_key_format" not in http_protocol_options
193 ), f"'header_key_format' found unexpected typed_config {typed_config}"
194 continue
195
196 assert (
197 "header_key_format" in http_protocol_options
198 ), f"'header_key_format' not found, typed_config {typed_config}"
199
200 header_key_format = http_protocol_options["header_key_format"]
201 assert (
202 "custom" in header_key_format
203 ), f"'custom' not found, typed_config {typed_config}"
204
205 rules = header_key_format["custom"]["rules"]
206 assert len(rules) == len(expectations)
207 for e in expectations:
208 hdr = e.lower()
209 assert hdr in rules
210 rule = rules[hdr]
211 assert rule == e, f"unexpected rule {rule} in {rules}"
212 found_module_rules = True
213
214 for cluster in conf["static_resources"]["clusters"]:
215 if "httpbin" not in cluster["name"]:
216 continue
217
218 http_protocol_options = cluster.get("http_protocol_options", None)
219 if not http_protocol_options:
220 if expect_norules:
221 continue
222 assert (
223 "http_protocol_options" in cluster
224 ), f"'http_protocol_options' missing from cluster: {cluster}"
225
226 if expect_norules:
227 assert (
228 "header_key_format" not in http_protocol_options
229 ), f"'header_key_format' found unexpected cluster: {cluster}"
230 continue
231
232 assert (
233 "header_key_format" in http_protocol_options
234 ), f"'header_key_format' not found, cluster {cluster}"
235
236 header_key_format = http_protocol_options["header_key_format"]
237 assert "custom" in header_key_format, f"'custom' not found, cluster {cluster}"
238
239 rules = header_key_format["custom"]["rules"]
240 assert len(rules) == len(expectations)
241 for e in expectations:
242 hdr = e.lower()
243 assert hdr in rules
244 rule = rules[hdr]
245 assert rule == e, f"unexpected rule {rule} in {rules}"
246 found_cluster_rules = True
247
248 if expect_norules:
249 assert not found_module_rules
250 assert not found_cluster_rules
251 else:
252 assert found_module_rules
253 assert found_cluster_rules
254
255
256def _test_headercaseoverrides_rules(rules, expected=None, expect_norules=False):
257 if not expected:
258 expected = rules
259 _test_headercaseoverrides(
260 _ambassador_module_header_case_overrides(rules),
261 expected,
262 expect_norules=expect_norules,
263 )
264
265
266# Test that we throw assertions for obviously wrong cases
267@pytest.mark.compilertest
268def test_testsanity():
269 failed = False
270 try:
271 _test_headercaseoverrides_rules(["X-ABC"], expected=["X-Wrong"])
272 except AssertionError as e:
273 failed = True
274 assert failed
275
276 failed = False
277 try:
278 _test_headercaseoverrides_rules([], expected=["X-Wrong"])
279 except AssertionError as e:
280 failed = True
281 assert failed
282
283
284# Test that we can parse a variety of header case override arrays.
285@pytest.mark.compilertest
286def test_headercaseoverrides_basic():
287 _test_headercaseoverrides_rules([], expect_norules=True)
288 _test_headercaseoverrides_rules([{}], expect_norules=True)
289 _test_headercaseoverrides_rules([5], expect_norules=True)
290 _test_headercaseoverrides_rules(["X-ABC"])
291 _test_headercaseoverrides_rules(["X-foo", "X-ABC-Baz"])
292 _test_headercaseoverrides_rules(["x-goOd", "X-alSo-good", "Authorization"])
293 _test_headercaseoverrides_rules(["x-good", ["hello"]], expected=["x-good"])
294 _test_headercaseoverrides_rules(["X-ABC", "x-foo", 5, {}], expected=["X-ABC", "x-foo"])
295
296
297# Test that we always omit header case overrides if proper case is set
298@pytest.mark.compilertest
299def test_headercaseoverrides_propercasefail():
300 _test_headercaseoverrides(
301 _ambassador_module_header_case_overrides(["My-OPINIONATED-CASING"], proper_case=True),
302 [],
303 expect_norules=True,
304 )
305 _test_headercaseoverrides(
306 _ambassador_module_header_case_overrides([], proper_case=True),
307 [],
308 expect_norules=True,
309 )
310 _test_headercaseoverrides(
311 _ambassador_module_header_case_overrides([{"invalid": "true"}, "X-COOL"], proper_case=True),
312 [],
313 expect_norules=True,
314 )
315
316
317class HeaderCaseOverridesTesting:
318 def create_module(self, namespace):
319 manifest = f"""
320---
321apiVersion: getambassador.io/v3alpha1
322kind: Module
323metadata:
324 name: ambassador
325spec:
326 config:
327 header_case_overrides:
328 - X-HELLO
329 - X-FOO-Bar
330 """
331
332 apply_kube_artifacts(namespace=namespace, artifacts=manifest)
333
334 def create_listeners(self, namespace):
335 manifest = f"""
336---
337apiVersion: getambassador.io/v3alpha1
338kind: Listener
339metadata:
340 name: listener-8080
341spec:
342 port: 8080
343 protocol: HTTP
344 securityModel: INSECURE
345 hostBinding:
346 namespace:
347 from: SELF
348"""
349
350 apply_kube_artifacts(namespace=namespace, artifacts=manifest)
351
352 def test_header_case_overrides(self):
353 # Is there any reason not to use the default namespace?
354 namespace = "header-case-overrides"
355
356 # Install Ambassador
357 install_ambassador(namespace=namespace)
358
359 # Install httpbin
360 apply_kube_artifacts(namespace=namespace, artifacts=httpbin_manifests)
361
362 # Install headerecho
363 apply_kube_artifacts(namespace=namespace, artifacts=headerecho_manifests)
364
365 # Install listeners.
366 self.create_listeners(namespace)
367
368 # Install module
369 self.create_module(namespace)
370
371 # Install httpbin mapping
372 create_httpbin_mapping(namespace=namespace)
373
374 # Install headerecho mapping
375 create_headerecho_mapping(namespace=namespace)
376
377 # Now let's wait for ambassador and httpbin pods to become ready
378 run_and_assert(
379 [
380 "tools/bin/kubectl",
381 "wait",
382 "--timeout=90s",
383 "--for=condition=Ready",
384 "pod",
385 "-l",
386 "service=ambassador",
387 "-n",
388 namespace,
389 ]
390 )
391 run_and_assert(
392 [
393 "tools/bin/kubectl",
394 "wait",
395 "--timeout=90s",
396 "--for=condition=Ready",
397 "pod",
398 "-l",
399 "service=httpbin",
400 "-n",
401 namespace,
402 ]
403 )
404
405 # Assume we can reach Ambassador through telepresence
406 ambassador_host = "ambassador." + namespace
407
408 # Assert 200 OK at httpbin/status/200 endpoint
409 ready = False
410 httpbin_url = f"http://{ambassador_host}/httpbin/status/200"
411 headerecho_url = f"http://{ambassador_host}/headerecho/"
412
413 loop_limit = 10
414 while not ready:
415 assert loop_limit > 0, "httpbin is not ready yet, aborting..."
416 try:
417 print(f"trying {httpbin_url}...")
418 resp = requests.get(httpbin_url, timeout=5)
419 code = resp.status_code
420 assert code == 200, f"Expected 200 OK, got {code}"
421 resp.close()
422 print(f"{httpbin_url} is ready")
423
424 print(f"trying {headerecho_url}...")
425 resp = requests.get(headerecho_url, timeout=5)
426 code = resp.status_code
427 assert code == 200, f"Expected 200 OK, got {code}"
428 resp.close()
429 print(f"{headerecho_url} is ready")
430
431 ready = True
432
433 except Exception as e:
434 print(f"Error: {e}")
435 print(f"{httpbin_url} not ready yet, trying again...")
436 time.sleep(1)
437 loop_limit -= 1
438
439 assert ready
440
441 httpbin_url = f"http://{ambassador_host}/httpbin/response-headers?x-Hello=1&X-foo-Bar=1&x-Lowercase1=1&x-lowercase2=1"
442 resp = requests.get(httpbin_url, timeout=5)
443 code = resp.status_code
444 assert code == 200, f"Expected 200 OK, got {code}"
445
446 # First, test that the response headers have the correct case.
447
448 # Very important: this test relies on matching case sensitive header keys.
449 # Fortunately it appears that we can convert resp.headers, a case insensitive
450 # dictionary, into a list of case sensitive keys.
451 keys = [h for h in resp.headers.keys()]
452 for k in keys:
453 print(f"header key: {k}")
454
455 assert "x-hello" not in keys
456 assert "X-HELLO" in keys
457 assert "x-foo-bar" not in keys
458 assert "X-FOO-Bar" in keys
459 assert "x-lowercase1" in keys
460 assert "x-Lowercase1" not in keys
461 assert "x-lowercase2" in keys
462 resp.close()
463
464 # Second, test that the request headers sent to the headerecho server
465 # have the correct case.
466
467 headers = {"x-Hello": "1", "X-foo-Bar": "1", "x-Lowercase1": "1", "x-lowercase2": "1"}
468 resp = requests.get(headerecho_url, headers=headers, timeout=5)
469 code = resp.status_code
470 assert code == 200, f"Expected 200 OK, got {code}"
471
472 response_obj = json.loads(resp.text)
473 print(f"response_obj = {response_obj}")
474 assert response_obj
475 assert "headers" in response_obj
476
477 hdrs = response_obj["headers"]
478 assert "x-hello" not in hdrs
479 assert "X-HELLO" in hdrs
480 assert "x-foo-bar" not in hdrs
481 assert "X-FOO-Bar" in hdrs
482 assert "x-lowercase1" in hdrs
483 assert "x-Lowercase1" not in hdrs
484 assert "x-lowercase2" in hdrs
485
486
487@pytest.mark.flaky(reruns=1, reruns_delay=10)
488def test_ambassador_headercaseoverrides():
489 t = HeaderCaseOverridesTesting()
490 t.test_header_case_overrides()
491
492
493if __name__ == "__main__":
494 pytest.main(sys.argv)
View as plain text