Skip to content

Commit 8030224

Browse files
yalosevname212
andauthored
[feat] Add test fixtures for conversion and validation webhooks and add base class for realisation conversion webhooks (#8)
Link (#7) Signed-off-by: Nikolay Mitrofanov <nikolay.mitrofanov@flant.com> Co-authored-by: Nikolay Mitrofanov <30695496+name212@users.noreply.github.com>
1 parent d87162e commit 8030224

File tree

2 files changed

+206
-0
lines changed

2 files changed

+206
-0
lines changed

deckhouse/tests.py

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright 2024 Flant JSC Licensed under Apache License 2.0
4+
#
5+
6+
import unittest
7+
import typing
8+
9+
from .hook import Output
10+
11+
12+
# msg: typing.Tuple[str, ...] | str | None
13+
def __assert_validation(t: unittest.TestCase, o: Output, allowed: bool, msg: typing.Union[typing.Tuple[str, ...], str, None]):
14+
v = o.validations
15+
16+
t.assertEqual(len(v.data), 1)
17+
18+
if allowed:
19+
t.assertTrue(v.data[0]["allowed"])
20+
21+
if not msg:
22+
return
23+
24+
if isinstance(msg, str):
25+
t.assertEqual(len(v.data[0]["warnings"]), 1)
26+
t.assertEqual(v.data[0]["warnings"][0], msg)
27+
elif isinstance(msg, tuple):
28+
t.assertEqual(v.data[0]["warnings"], msg)
29+
else:
30+
t.fail("Incorrect msg type")
31+
else:
32+
if not isinstance(msg, str):
33+
t.fail("Incorrect msg type")
34+
35+
t.assertIsNotNone(msg)
36+
t.assertIsInstance(msg, str)
37+
t.assertFalse(v.data[0]["allowed"])
38+
t.assertEqual(v.data[0]["message"], msg)
39+
40+
41+
# msg: typing.Tuple[str, ...] | str | None
42+
def assert_validation_allowed(t: unittest.TestCase, o: Output, msg: typing.Union[typing.Tuple[str, ...], str, None]):
43+
"""
44+
Assert that validation webhook returns "allowed" result
45+
46+
Args:
47+
t (unittest.TestCase): unit test context (self in Test class method)
48+
o (hook.Output): output from hook.testrun
49+
msg (any): tuple or str or None, warnings for output, tuple for multiple warnings, str for one warning, None without warnings
50+
"""
51+
__assert_validation(t, o, True, msg)
52+
53+
54+
def assert_validation_deny(t: unittest.TestCase, o: Output, msg: str):
55+
"""
56+
Assert that validation webhook returns "deny" result
57+
58+
Args:
59+
t (unittest.TestCase): unit test context (self in Test class method)
60+
o (hook.Output): output from hook.testrun
61+
msg (str): failed message
62+
"""
63+
__assert_validation(t, o, False, msg)
64+
65+
66+
def assert_common_resource_fields(t: unittest.TestCase, obj: dict, api_version: str, name: str, namespace: str = ""):
67+
"""
68+
Assert for object represented as dict api version name and namespace
69+
This fixture may be useful for conversion webhook tests for checking
70+
that conversion webhook did not change name and namespace and set valid api version
71+
72+
Args:
73+
t (unittest.TestCase): unit test context (self in Test class method)
74+
obj (hook.Output): output from hook.testrun
75+
api_version (str): API version for expected object
76+
name (str): name of expected object
77+
namespace (str): namespace of expected object
78+
"""
79+
80+
t.assertIn("apiVersion", obj)
81+
t.assertEqual(obj["apiVersion"], api_version)
82+
83+
t.assertIn("metadata", obj)
84+
85+
t.assertIn("name", obj["metadata"])
86+
t.assertEqual(obj["metadata"]["name"], name)
87+
88+
if namespace:
89+
t.assertIn("namespace", obj["metadata"])
90+
t.assertEqual(obj["metadata"]["namespace"], namespace)
91+
92+
# res: dict | typing.List[dict] | typing.Callable[[unittest.TestCase, typing.List[dict]], None]
93+
def assert_conversion(t: unittest.TestCase, o: Output, res: typing.Union[dict, typing.List[dict], typing.Callable[[unittest.TestCase, typing.List[dict]], None]], failed_msg: str):
94+
"""
95+
Assert result of conversion webhook
96+
97+
Args:
98+
t (unittest.TestCase): unit test context (self in Test class method)
99+
o (hook.Output): output from hook.testrun
100+
res (any): Can be: dict - for one resource convertion, list of dicts for conversion multiple objects per request
101+
or function callable[ (unittest.TestCase, typing.List[dict]) -> None ] for assert objects for your manner
102+
failed_msg (str | None): should present for asserting failed result of webhook
103+
"""
104+
105+
d = o.conversions.data
106+
107+
t.assertEqual(len(d), 1)
108+
109+
if not failed_msg is None:
110+
t.assertEqual(len(d[0]), 1)
111+
t.assertEqual(d[0]["failedMessage"], failed_msg)
112+
return
113+
114+
if callable(res):
115+
res(t, d[0]["convertedObjects"])
116+
return
117+
118+
expected = res
119+
if isinstance(res, dict):
120+
expected = [res]
121+
122+
123+
t.assertEqual(d[0]["convertedObjects"], expected)

deckhouse/utils.py

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright 2024 Flant JSC Licensed under Apache License 2.0
4+
#
5+
6+
from .hook import Context
7+
8+
9+
class BaseConversionHook:
10+
"""
11+
Base class for convertion webhook realisation.
12+
Usage.
13+
Create class realisation with methods which named as kubernetesCustomResourceConversion[*].name and
14+
which get dict resource for conversion and returns tuple (string|None, dict) with result
15+
if string is not None conversion webhook will return error.
16+
17+
For example. We have next conversion webhook declaration:
18+
configVersion: v1
19+
kubernetesCustomResourceConversion:
20+
- name: alpha1_to_alpha2
21+
crdName: nodegroups.deckhouse.io
22+
conversions:
23+
- fromVersion: deckhouse.io/v1alpha1
24+
toVersion: deckhouse.io/v1alpha2
25+
26+
Then we can create next class for this conversion:
27+
28+
class NodeGroupConversion(ConversionDispatcher):
29+
def __init__(self, ctx: Context):
30+
super().__init__(ctx)
31+
32+
def alpha1_to_alpha2(self, o: dict) -> typing.Tuple[str | None, dict]:
33+
o["apiVersion"] = "deckhouse.io/v1alpha2"
34+
return None, o
35+
36+
We added method alpha1_to_alpha2 (named as binding name for conversion), get dict for conversion and returns a tuple.
37+
38+
And in hook file we can use this class in the next way:
39+
def main(ctx: hook.Context):
40+
NodeGroupConversion(ctx).run()
41+
42+
if __name__ == "__main__":
43+
hook.run(main, config=config)
44+
"""
45+
def __init__(self, ctx: Context):
46+
self._binding_context = ctx.binding_context
47+
self._snapshots = ctx.snapshots
48+
self.__ctx = ctx
49+
50+
51+
def run(self):
52+
binding_name = self._binding_context["binding"]
53+
54+
try:
55+
action = getattr(self, binding_name)
56+
except AttributeError:
57+
self.__ctx.output.conversions.error("Internal error. Handler for binding {} not found".format(binding_name))
58+
return
59+
60+
try:
61+
errors = []
62+
from_version = self._binding_context["fromVersion"]
63+
to_version = self._binding_context["toVersion"]
64+
for obj in self._binding_context["review"]["request"]["objects"]:
65+
if from_version != obj["apiVersion"]:
66+
self.__ctx.output.conversions.collect(obj)
67+
continue
68+
69+
error_msg, res_obj = action(obj)
70+
if error_msg is not None:
71+
errors.append(error_msg)
72+
continue
73+
74+
assert res_obj["apiVersion"] == to_version
75+
76+
self.__ctx.output.conversions.collect(res_obj)
77+
if errors:
78+
err_msg = ";".join(errors)
79+
self.__ctx.output.conversions.error(err_msg)
80+
except Exception as e:
81+
self.__ctx.output.conversions.error("Internal error: {}".format(str(e)))
82+
return
83+

0 commit comments

Comments
 (0)