Skip to content

Commit bc26b6d

Browse files
Ability to use expressions in a json patch
Introducing the JsonPatchParserV2. This new json patch parser is backwards compatible with the already supported json patch operations. On top of that, it adds the ability to use expressions to generate new values on a json patch operation. For example, we can add a prefix to the pod name: ``` op: expr path: .metadata.name value: '"prefix-" ++ .metadata.name' ```
1 parent 7b55c89 commit bc26b6d

File tree

5 files changed

+167
-64
lines changed

5 files changed

+167
-64
lines changed

generic_k8s_webhook/config_parser/action_parser.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def parse(self, raw_config: dict, path_action: str) -> Action:
4545
condition = self.meta_op_parser.parse(raw_condition, f"{path_action}.condition")
4646

4747
raw_patch = raw_config.pop("patch", [])
48-
patch = self.json_patch_parser.parse(raw_patch)
48+
patch = self.json_patch_parser.parse(raw_patch, f"{path_action}.patch")
4949

5050
# By default, we always accept the payload
5151
accept = raw_config.pop("accept", True)

generic_k8s_webhook/config_parser/entrypoint.py

+23-22
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from generic_k8s_webhook import utils
55
from generic_k8s_webhook.config_parser import expr_parser
66
from generic_k8s_webhook.config_parser.action_parser import ActionParserV1
7-
from generic_k8s_webhook.config_parser.jsonpatch_parser import JsonPatchParserV1
7+
from generic_k8s_webhook.config_parser.jsonpatch_parser import JsonPatchParserV1, JsonPatchParserV2
88
from generic_k8s_webhook.config_parser.webhook_parser import WebhookParserV1
99
from generic_k8s_webhook.webhook import Webhook
1010

@@ -95,29 +95,30 @@ def _parse_v1alpha1(self, raw_list_webhook_config: dict) -> list[Webhook]:
9595
return list_webhook_config
9696

9797
def _parse_v1beta1(self, raw_list_webhook_config: dict) -> list[Webhook]:
98+
meta_op_parser = op_parser.MetaOperatorParser(
99+
list_op_parser_classes=[
100+
op_parser.AndParser,
101+
op_parser.AllParser,
102+
op_parser.OrParser,
103+
op_parser.AnyParser,
104+
op_parser.EqualParser,
105+
op_parser.SumParser,
106+
op_parser.StrConcatParser,
107+
op_parser.NotParser,
108+
op_parser.ListParser,
109+
op_parser.ForEachParser,
110+
op_parser.MapParser,
111+
op_parser.ContainParser,
112+
op_parser.FilterParser,
113+
op_parser.ConstParser,
114+
op_parser.GetValueParser,
115+
],
116+
raw_str_parser=expr_parser.RawStringParserV1(),
117+
)
98118
webhook_parser = WebhookParserV1(
99119
action_parser=ActionParserV1(
100-
meta_op_parser=op_parser.MetaOperatorParser(
101-
list_op_parser_classes=[
102-
op_parser.AndParser,
103-
op_parser.AllParser,
104-
op_parser.OrParser,
105-
op_parser.AnyParser,
106-
op_parser.EqualParser,
107-
op_parser.SumParser,
108-
op_parser.StrConcatParser,
109-
op_parser.NotParser,
110-
op_parser.ListParser,
111-
op_parser.ForEachParser,
112-
op_parser.MapParser,
113-
op_parser.ContainParser,
114-
op_parser.FilterParser,
115-
op_parser.ConstParser,
116-
op_parser.GetValueParser,
117-
],
118-
raw_str_parser=expr_parser.RawStringParserV1(),
119-
),
120-
json_patch_parser=JsonPatchParserV1(),
120+
meta_op_parser=meta_op_parser,
121+
json_patch_parser=JsonPatchParserV2(meta_op_parser),
121122
)
122123
)
123124
list_webhook_config = [
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,104 @@
11
import abc
22

3+
import generic_k8s_webhook.config_parser.operator_parser as op_parser
34
from generic_k8s_webhook import jsonpatch_helpers, utils
5+
from generic_k8s_webhook.config_parser.common import ParsingException
46

57

6-
class IJsonPatchParser(abc.ABC):
8+
class ParserOp(abc.ABC):
79
@abc.abstractmethod
8-
def parse(self, raw_patch: list) -> list[jsonpatch_helpers.JsonPatchOperator]:
10+
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
911
pass
1012

13+
def _parse_path(self, raw_elem: dict, key: str) -> list[str]:
14+
raw_path = utils.must_pop(raw_elem, key, f"Missing key {key} in {raw_elem}")
15+
path = utils.convert_dot_string_path_to_list(raw_path)
16+
if path[0] != "":
17+
raise ValueError(f"The first element of a path in the patch must be '.', not {path[0]}")
18+
return path[1:]
19+
20+
21+
class ParseAdd(ParserOp):
22+
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
23+
path = self._parse_path(raw_elem, "path")
24+
value = utils.must_pop(raw_elem, "value", f"Missing key 'value' in {raw_elem}")
25+
return jsonpatch_helpers.JsonPatchAdd(path, value)
26+
27+
28+
class ParseRemove(ParserOp):
29+
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
30+
path = self._parse_path(raw_elem, "path")
31+
return jsonpatch_helpers.JsonPatchRemove(path)
32+
33+
34+
class ParseReplace(ParserOp):
35+
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
36+
path = self._parse_path(raw_elem, "path")
37+
value = utils.must_pop(raw_elem, "value", f"Missing key 'value' in {raw_elem}")
38+
return jsonpatch_helpers.JsonPatchReplace(path, value)
39+
40+
41+
class ParseCopy(ParserOp):
42+
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
43+
path = self._parse_path(raw_elem, "path")
44+
fromm = self._parse_path(raw_elem, "from")
45+
return jsonpatch_helpers.JsonPatchCopy(path, fromm)
46+
47+
48+
class ParseMove(ParserOp):
49+
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
50+
path = self._parse_path(raw_elem, "path")
51+
fromm = self._parse_path(raw_elem, "from")
52+
return jsonpatch_helpers.JsonPatchMove(path, fromm)
53+
54+
55+
class ParseTest(ParserOp):
56+
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
57+
path = self._parse_path(raw_elem, "path")
58+
value = utils.must_pop(raw_elem, "value", f"Missing key 'value' in {raw_elem}")
59+
return jsonpatch_helpers.JsonPatchTest(path, value)
60+
61+
62+
class ParseExpr(ParserOp):
63+
def __init__(self, meta_op_parser: op_parser.MetaOperatorParser) -> None:
64+
self.meta_op_parser = meta_op_parser
65+
66+
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
67+
path = self._parse_path(raw_elem, "path")
68+
value = utils.must_pop(raw_elem, "value", f"Missing key 'value' in {raw_elem}")
69+
operator = self.meta_op_parser.parse(value, f"{path_op}.value")
70+
return jsonpatch_helpers.JsonPatchExpr(path, operator)
71+
72+
73+
class IJsonPatchParser(abc.ABC):
74+
def parse(self, raw_patch: list, path_op: str) -> list[jsonpatch_helpers.JsonPatchOperator]:
75+
patch = []
76+
dict_parse_op = self._get_dict_parse_op()
77+
for i, raw_elem in enumerate(raw_patch):
78+
op = utils.must_pop(raw_elem, "op", f"Missing key 'op' in {raw_elem}")
79+
80+
# Select the appropiate class needed to parse the operation "op"
81+
if op not in dict_parse_op:
82+
raise ParsingException(f"Unsupported patch operation {op} on {path_op}")
83+
parse_op = dict_parse_op[op]
84+
try:
85+
parsed_elem = parse_op.parse(raw_elem, f"{path_op}.{i}")
86+
except Exception as e:
87+
raise ParsingException(f"Error when parsing {path_op}") from e
88+
89+
# Make sure we have extracted all the keys from "raw_elem"
90+
if len(raw_elem) > 0:
91+
raise ValueError(f"Unexpected keys {raw_elem}")
92+
patch.append(parsed_elem)
93+
94+
return patch
95+
96+
@abc.abstractmethod
97+
def _get_dict_parse_op(self) -> dict[str, ParserOp]:
98+
"""A dictionary with the classes that can parse the json patch operations
99+
supported by this JsonPatchParser
100+
"""
101+
11102

12103
class JsonPatchParserV1(IJsonPatchParser):
13104
"""Class used to parse a json patch spec V1. Example:
@@ -19,45 +110,28 @@ class JsonPatchParserV1(IJsonPatchParser):
19110
```
20111
"""
21112

22-
def parse(self, raw_patch: list) -> list[jsonpatch_helpers.JsonPatchOperator]:
23-
patch = []
24-
for raw_elem in raw_patch:
25-
op = utils.must_pop(raw_elem, "op", f"Missing key 'op' in {raw_elem}")
26-
if op == "add":
27-
path = self._parse_path(raw_elem, "path")
28-
value = utils.must_pop(raw_elem, "value", f"Missing key 'value' in {raw_elem}")
29-
parsed_elem = jsonpatch_helpers.JsonPatchAdd(path, value)
30-
elif op == "remove":
31-
path = self._parse_path(raw_elem, "path")
32-
parsed_elem = jsonpatch_helpers.JsonPatchRemove(path)
33-
elif op == "replace":
34-
path = self._parse_path(raw_elem, "path")
35-
value = utils.must_pop(raw_elem, "value", f"Missing key 'value' in {raw_elem}")
36-
parsed_elem = jsonpatch_helpers.JsonPatchReplace(path, value)
37-
elif op == "copy":
38-
path = self._parse_path(raw_elem, "path")
39-
fromm = self._parse_path(raw_elem, "from")
40-
parsed_elem = jsonpatch_helpers.JsonPatchCopy(path, fromm)
41-
elif op == "move":
42-
path = self._parse_path(raw_elem, "path")
43-
fromm = self._parse_path(raw_elem, "from")
44-
parsed_elem = jsonpatch_helpers.JsonPatchMove(path, fromm)
45-
elif op == "test":
46-
path = self._parse_path(raw_elem, "path")
47-
value = utils.must_pop(raw_elem, "value", f"Missing key 'value' in {raw_elem}")
48-
parsed_elem = jsonpatch_helpers.JsonPatchTest(path, value)
49-
else:
50-
raise ValueError(f"Invalid patch operation {raw_elem['op']}")
113+
def _get_dict_parse_op(self) -> dict[str, ParserOp]:
114+
return {
115+
"add": ParseAdd(),
116+
"remove": ParseRemove(),
117+
"replace": ParseReplace(),
118+
"copy": ParseCopy(),
119+
"move": ParseMove(),
120+
"test": ParseTest(),
121+
}
51122

52-
if len(raw_elem) > 0:
53-
raise ValueError(f"Unexpected keys {raw_elem}")
54-
patch.append(parsed_elem)
55123

56-
return patch
124+
class JsonPatchParserV2(JsonPatchParserV1):
125+
"""Class used to parse a json patch spec V2. It supports the same actions as the
126+
json patch patch spec V1 plus the ability use expressions to create new values
127+
"""
57128

58-
def _parse_path(self, raw_elem: dict, key: str) -> list[str]:
59-
raw_path = utils.must_pop(raw_elem, key, f"Missing key {key} in {raw_elem}")
60-
path = utils.convert_dot_string_path_to_list(raw_path)
61-
if path[0] != "":
62-
raise ValueError(f"The first element of a path in the patch must be '.', not {path[0]}")
63-
return path[1:]
129+
def __init__(self, meta_op_parser: op_parser.MetaOperatorParser) -> None:
130+
self.meta_op_parser = meta_op_parser
131+
132+
def _get_dict_parse_op(self) -> dict[str, ParserOp]:
133+
dict_parse_op_v1 = super()._get_dict_parse_op()
134+
dict_parse_op_v2 = {
135+
"expr": ParseExpr(self.meta_op_parser),
136+
}
137+
return {**dict_parse_op_v1, **dict_parse_op_v2}

generic_k8s_webhook/jsonpatch_helpers.py

+17
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import jsonpatch
55

6+
from generic_k8s_webhook import operators
67
from generic_k8s_webhook.utils import to_number
78

89

@@ -134,3 +135,19 @@ def __init__(self, path: list[str], value: Any) -> None:
134135
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
135136
formatted_path = "/" + "/".join(self.path)
136137
return jsonpatch.JsonPatch([{"op": "test", "path": formatted_path, "value": self.value}])
138+
139+
140+
class JsonPatchExpr(JsonPatchOperator):
141+
"""It's similar to the JsonPatchAdd, but it first dynamically evaluates the actual value
142+
expressed under the "value" keyword and then performs a normal "add" operation using
143+
this new value
144+
"""
145+
146+
def __init__(self, path: list[str], value: operators.Operator) -> None:
147+
super().__init__(path)
148+
self.value = value
149+
150+
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
151+
actual_value = self.value.get_value([json_to_patch])
152+
json_patch_add = JsonPatchAdd(self.path, actual_value)
153+
return json_patch_add.generate_patch(json_to_patch)

tests/jsonpatch_test.yaml

+11
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,14 @@ test_suites:
126126
value: foo
127127
payload: { spec: {}, metadata: { name: foo } }
128128
expected_result: { spec: {}, metadata: { name: foo } }
129+
- name: EXPR
130+
tests:
131+
- schemas: [v1beta1]
132+
cases:
133+
# Add a prefix
134+
- patch:
135+
op: expr
136+
path: .metadata.name
137+
value: '"prefix-" ++ .metadata.name'
138+
payload: { spec: {}, metadata: { name: foo } }
139+
expected_result: { spec: {}, metadata: { name: prefix-foo } }

0 commit comments

Comments
 (0)