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, version="V3"):
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, version)
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, version="V3"):
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 version=version,
264 )
265
266
267# Test that we throw assertions for obviously wrong cases
268@pytest.mark.compilertest
269def test_testsanity():
270 failed = False
271 for version in ["V2", "V3"]:
272 try:
273 _test_headercaseoverrides_rules(["X-ABC"], expected=["X-Wrong"], version=version)
274 except AssertionError as e:
275 failed = True
276 assert failed
277
278 failed = False
279 try:
280 _test_headercaseoverrides_rules([], expected=["X-Wrong"], version=version)
281 except AssertionError as e:
282 failed = True
283 assert failed
284
285
286# Test that we can parse a variety of header case override arrays.
287@pytest.mark.compilertest
288def test_headercaseoverrides_basic():
289 for version in ["V2", "V3"]:
290 _test_headercaseoverrides_rules([], expect_norules=True, version=version)
291 _test_headercaseoverrides_rules([{}], expect_norules=True, version=version)
292 _test_headercaseoverrides_rules([5], expect_norules=True, version=version)
293 _test_headercaseoverrides_rules(["X-ABC"], version=version)
294 _test_headercaseoverrides_rules(["X-foo", "X-ABC-Baz"], version=version)
295 _test_headercaseoverrides_rules(["x-goOd", "X-alSo-good", "Authorization"], version=version)
296 _test_headercaseoverrides_rules(["x-good", ["hello"]], expected=["x-good"], version=version)
297 _test_headercaseoverrides_rules(
298 ["X-ABC", "x-foo", 5, {}], expected=["X-ABC", "x-foo"], version=version
299 )
300
301
302# Test that we always omit header case overrides if proper case is set
303@pytest.mark.compilertest
304def test_headercaseoverrides_propercasefail():
305 for version in ["V2", "V3"]:
306 _test_headercaseoverrides(
307 _ambassador_module_header_case_overrides(["My-OPINIONATED-CASING"], proper_case=True),
308 [],
309 expect_norules=True,
310 version=version,
311 )
312 _test_headercaseoverrides(
313 _ambassador_module_header_case_overrides([], proper_case=True),
314 [],
315 expect_norules=True,
316 version=version,
317 )
318 _test_headercaseoverrides(
319 _ambassador_module_header_case_overrides(
320 [{"invalid": "true"}, "X-COOL"], proper_case=True
321 ),
322 [],
323 expect_norules=True,
324 version=version,
325 )
326
327
328class HeaderCaseOverridesTesting:
329 def create_module(self, namespace):
330 manifest = f"""
331---
332apiVersion: getambassador.io/v3alpha1
333kind: Module
334metadata:
335 name: ambassador
336spec:
337 config:
338 header_case_overrides:
339 - X-HELLO
340 - X-FOO-Bar
341 """
342
343 apply_kube_artifacts(namespace=namespace, artifacts=manifest)
344
345 def create_listeners(self, namespace):
346 manifest = f"""
347---
348apiVersion: getambassador.io/v3alpha1
349kind: Listener
350metadata:
351 name: listener-8080
352spec:
353 port: 8080
354 protocol: HTTP
355 securityModel: INSECURE
356 hostBinding:
357 namespace:
358 from: SELF
359"""
360
361 apply_kube_artifacts(namespace=namespace, artifacts=manifest)
362
363 def test_header_case_overrides(self):
364 # Is there any reason not to use the default namespace?
365 namespace = "header-case-overrides"
366
367 # Install Ambassador
368 install_ambassador(namespace=namespace)
369
370 # Install httpbin
371 apply_kube_artifacts(namespace=namespace, artifacts=httpbin_manifests)
372
373 # Install headerecho
374 apply_kube_artifacts(namespace=namespace, artifacts=headerecho_manifests)
375
376 # Install listeners.
377 self.create_listeners(namespace)
378
379 # Install module
380 self.create_module(namespace)
381
382 # Install httpbin mapping
383 create_httpbin_mapping(namespace=namespace)
384
385 # Install headerecho mapping
386 create_headerecho_mapping(namespace=namespace)
387
388 # Now let's wait for ambassador and httpbin pods to become ready
389 run_and_assert(
390 [
391 "tools/bin/kubectl",
392 "wait",
393 "--timeout=90s",
394 "--for=condition=Ready",
395 "pod",
396 "-l",
397 "service=ambassador",
398 "-n",
399 namespace,
400 ]
401 )
402 run_and_assert(
403 [
404 "tools/bin/kubectl",
405 "wait",
406 "--timeout=90s",
407 "--for=condition=Ready",
408 "pod",
409 "-l",
410 "service=httpbin",
411 "-n",
412 namespace,
413 ]
414 )
415
416 # Assume we can reach Ambassador through telepresence
417 ambassador_host = "ambassador." + namespace
418
419 # Assert 200 OK at httpbin/status/200 endpoint
420 ready = False
421 httpbin_url = f"http://{ambassador_host}/httpbin/status/200"
422 headerecho_url = f"http://{ambassador_host}/headerecho/"
423
424 loop_limit = 10
425 while not ready:
426 assert loop_limit > 0, "httpbin is not ready yet, aborting..."
427 try:
428 print(f"trying {httpbin_url}...")
429 resp = requests.get(httpbin_url, timeout=5)
430 code = resp.status_code
431 assert code == 200, f"Expected 200 OK, got {code}"
432 resp.close()
433 print(f"{httpbin_url} is ready")
434
435 print(f"trying {headerecho_url}...")
436 resp = requests.get(headerecho_url, timeout=5)
437 code = resp.status_code
438 assert code == 200, f"Expected 200 OK, got {code}"
439 resp.close()
440 print(f"{headerecho_url} is ready")
441
442 ready = True
443
444 except Exception as e:
445 print(f"Error: {e}")
446 print(f"{httpbin_url} not ready yet, trying again...")
447 time.sleep(1)
448 loop_limit -= 1
449
450 assert ready
451
452 httpbin_url = f"http://{ambassador_host}/httpbin/response-headers?x-Hello=1&X-foo-Bar=1&x-Lowercase1=1&x-lowercase2=1"
453 resp = requests.get(httpbin_url, timeout=5)
454 code = resp.status_code
455 assert code == 200, f"Expected 200 OK, got {code}"
456
457 # First, test that the response headers have the correct case.
458
459 # Very important: this test relies on matching case sensitive header keys.
460 # Fortunately it appears that we can convert resp.headers, a case insensitive
461 # dictionary, into a list of case sensitive keys.
462 keys = [h for h in resp.headers.keys()]
463 for k in keys:
464 print(f"header key: {k}")
465
466 assert "x-hello" not in keys
467 assert "X-HELLO" in keys
468 assert "x-foo-bar" not in keys
469 assert "X-FOO-Bar" in keys
470 assert "x-lowercase1" in keys
471 assert "x-Lowercase1" not in keys
472 assert "x-lowercase2" in keys
473 resp.close()
474
475 # Second, test that the request headers sent to the headerecho server
476 # have the correct case.
477
478 headers = {"x-Hello": "1", "X-foo-Bar": "1", "x-Lowercase1": "1", "x-lowercase2": "1"}
479 resp = requests.get(headerecho_url, headers=headers, timeout=5)
480 code = resp.status_code
481 assert code == 200, f"Expected 200 OK, got {code}"
482
483 response_obj = json.loads(resp.text)
484 print(f"response_obj = {response_obj}")
485 assert response_obj
486 assert "headers" in response_obj
487
488 hdrs = response_obj["headers"]
489 assert "x-hello" not in hdrs
490 assert "X-HELLO" in hdrs
491 assert "x-foo-bar" not in hdrs
492 assert "X-FOO-Bar" in hdrs
493 assert "x-lowercase1" in hdrs
494 assert "x-Lowercase1" not in hdrs
495 assert "x-lowercase2" in hdrs
496
497
498@pytest.mark.flaky(reruns=1, reruns_delay=10)
499def test_ambassador_headercaseoverrides():
500 t = HeaderCaseOverridesTesting()
501 t.test_header_case_overrides()
502
503
504if __name__ == "__main__":
505 pytest.main(sys.argv)
View as plain text