Skip to content

Commit 7ec6b28

Browse files
Ability to use a forEach on a jsonpatch
Now, we can iterate over a list and apply a jsonpatch on each element of the list. For example, we can remove the limits on all the containers from a pod: ```yaml patch: op: forEach elements: .spec.containers patch: - op: remove path: .limits ```
1 parent bc26b6d commit 7ec6b28

File tree

8 files changed

+226
-81
lines changed

8 files changed

+226
-81
lines changed

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ format: build
2929

3030
.PHONY: unittests
3131
unittests: build
32-
poetry run pytest tests
32+
poetry run pytest tests --cov=generic_k8s_webhook
3333
poetry run coverage html
3434

3535
.PHONY: check-pyproject

generic_k8s_webhook/config_parser/expr_parser.py

-1
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,6 @@ def __init__(self) -> None:
167167

168168
def parse(self, raw_string: str) -> op.Operator:
169169
tree = self.parser.parse(raw_string)
170-
print(tree.pretty()) # debug mode
171170
operator = self.transformer.transform(tree)
172171
return operator
173172

generic_k8s_webhook/config_parser/jsonpatch_parser.py

+48-31
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import abc
22

33
import generic_k8s_webhook.config_parser.operator_parser as op_parser
4-
from generic_k8s_webhook import jsonpatch_helpers, utils
4+
from generic_k8s_webhook import jsonpatch_helpers, operators, utils
55
from generic_k8s_webhook.config_parser.common import ParsingException
66

77

@@ -18,6 +18,36 @@ def _parse_path(self, raw_elem: dict, key: str) -> list[str]:
1818
return path[1:]
1919

2020

21+
class IJsonPatchParser(abc.ABC):
22+
def parse(self, raw_patch: list, path_op: str) -> list[jsonpatch_helpers.JsonPatchOperator]:
23+
patch = []
24+
dict_parse_op = self._get_dict_parse_op()
25+
for i, raw_elem in enumerate(raw_patch):
26+
op = utils.must_pop(raw_elem, "op", f"Missing key 'op' in {raw_elem}")
27+
28+
# Select the appropiate class needed to parse the operation "op"
29+
if op not in dict_parse_op:
30+
raise ParsingException(f"Unsupported patch operation {op} on {path_op}")
31+
parse_op = dict_parse_op[op]
32+
try:
33+
parsed_elem = parse_op.parse(raw_elem, f"{path_op}.{i}")
34+
except Exception as e:
35+
raise ParsingException(f"Error when parsing {path_op}") from e
36+
37+
# Make sure we have extracted all the keys from "raw_elem"
38+
if len(raw_elem) > 0:
39+
raise ValueError(f"Unexpected keys {raw_elem}")
40+
patch.append(parsed_elem)
41+
42+
return patch
43+
44+
@abc.abstractmethod
45+
def _get_dict_parse_op(self) -> dict[str, ParserOp]:
46+
"""A dictionary with the classes that can parse the json patch operations
47+
supported by this JsonPatchParser
48+
"""
49+
50+
2151
class ParseAdd(ParserOp):
2252
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
2353
path = self._parse_path(raw_elem, "path")
@@ -70,34 +100,23 @@ def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOper
70100
return jsonpatch_helpers.JsonPatchExpr(path, operator)
71101

72102

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
103+
class ParseForEach(ParserOp):
104+
def __init__(self, meta_op_parser: op_parser.MetaOperatorParser, jsonpatch_parser: IJsonPatchParser) -> None:
105+
self.meta_op_parser = meta_op_parser
106+
self.jsonpatch_parser = jsonpatch_parser
95107

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-
"""
108+
def parse(self, raw_elem: dict, path_op: str) -> jsonpatch_helpers.JsonPatchOperator:
109+
elems = utils.must_pop(raw_elem, "elements", f"Missing key 'elements' in {raw_elem}")
110+
op = self.meta_op_parser.parse(elems, f"{path_op}.elements")
111+
if not isinstance(op, operators.OperatorWithRef):
112+
raise ParsingException(
113+
f"The expression in {path_op}.elements must reference elements in the json that we want to patch"
114+
)
115+
list_raw_patch = utils.must_pop(raw_elem, "patch", f"Missing key 'patch' in {raw_elem}")
116+
if not isinstance(list_raw_patch, list):
117+
raise ParsingException(f"In {path_op}.patch we expect a list of patch, but got {list_raw_patch}")
118+
jsonpatch_op = self.jsonpatch_parser.parse(list_raw_patch, f"{path_op}.patch")
119+
return jsonpatch_helpers.JsonPatchForEach(op, jsonpatch_op)
101120

102121

103122
class JsonPatchParserV1(IJsonPatchParser):
@@ -131,7 +150,5 @@ def __init__(self, meta_op_parser: op_parser.MetaOperatorParser) -> None:
131150

132151
def _get_dict_parse_op(self) -> dict[str, ParserOp]:
133152
dict_parse_op_v1 = super()._get_dict_parse_op()
134-
dict_parse_op_v2 = {
135-
"expr": ParseExpr(self.meta_op_parser),
136-
}
153+
dict_parse_op_v2 = {"expr": ParseExpr(self.meta_op_parser), "forEach": ParseForEach(self.meta_op_parser, self)}
137154
return {**dict_parse_op_v1, **dict_parse_op_v2}

generic_k8s_webhook/jsonpatch_helpers.py

+67-27
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import abc
2-
from typing import Any
2+
from typing import Any, Union
33

44
import jsonpatch
55

@@ -12,17 +12,32 @@ def __init__(self, path: list[str]) -> None:
1212
self.path = path
1313

1414
@abc.abstractmethod
15-
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
15+
def generate_patch(self, contexts: list[Union[list, dict]], prefix: list[str] = None) -> jsonpatch.JsonPatch:
1616
pass
1717

18+
def _format_path(self, path: list[str], prefix: list[str]) -> str:
19+
"""Converts the `path` to a string separated by "/" and starts also by "/"
20+
If a prefix is defined and the path is not absolute, then the prefix is preprended.
21+
An absolute path is one whose first element is "$"
22+
"""
23+
if path[0] == "$":
24+
final_path = path[1:]
25+
elif prefix:
26+
final_path = prefix + path
27+
else:
28+
final_path = path
29+
final_path = [str(elem) for elem in final_path]
30+
return "/" + "/".join(final_path)
31+
1832

1933
class JsonPatchAdd(JsonPatchOperator):
2034
def __init__(self, path: list[str], value: Any) -> None:
2135
super().__init__(path)
2236
self.value = value
2337

2438
# Remember the op "add" is like an assignment
25-
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
39+
def generate_patch(self, contexts: list[Union[list, dict]], prefix: list[str] = None) -> jsonpatch.JsonPatch:
40+
json_to_patch = contexts[-1]
2641
# Check how many (nested) keys already exist
2742
existing_path = []
2843
first_non_existing_key = None
@@ -67,29 +82,25 @@ def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
6782
else:
6883
new_value = {key: new_value}
6984

70-
# Convert the list to a string separated by "/"
71-
formatted_path = "/" + "/".join(new_path)
72-
7385
return jsonpatch.JsonPatch(
7486
[
7587
{
7688
"op": "add",
77-
"path": formatted_path,
89+
"path": self._format_path(new_path, prefix),
7890
"value": new_value,
7991
}
8092
]
8193
)
8294

8395

8496
class JsonPatchRemove(JsonPatchOperator):
85-
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
97+
def generate_patch(self, contexts: list[Union[list, dict]], prefix: list[str] = None) -> jsonpatch.JsonPatch:
8698
# TODO If the key to remove doesn't exist, this must become a no-op
87-
formatted_path = "/" + "/".join(self.path)
8899
return jsonpatch.JsonPatch(
89100
[
90101
{
91102
"op": "remove",
92-
"path": formatted_path,
103+
"path": self._format_path(self.path, prefix),
93104
}
94105
]
95106
)
@@ -100,41 +111,53 @@ def __init__(self, path: list[str], value: Any) -> None:
100111
super().__init__(path)
101112
self.value = value
102113

103-
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
104-
formatted_path = "/" + "/".join(self.path)
105-
return jsonpatch.JsonPatch([{"op": "replace", "path": formatted_path, "value": self.value}])
114+
def generate_patch(self, contexts: list[Union[list, dict]], prefix: list[str] = None) -> jsonpatch.JsonPatch:
115+
return jsonpatch.JsonPatch(
116+
[{"op": "replace", "path": self._format_path(self.path, prefix), "value": self.value}]
117+
)
106118

107119

108120
class JsonPatchCopy(JsonPatchOperator):
109121
def __init__(self, path: list[str], fromm: Any) -> None:
110122
super().__init__(path)
111123
self.fromm = fromm
112124

113-
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
114-
formatted_path = "/" + "/".join(self.path)
115-
formatted_from = "/" + "/".join(self.fromm)
116-
return jsonpatch.JsonPatch([{"op": "copy", "path": formatted_path, "from": formatted_from}])
125+
def generate_patch(self, contexts: list[Union[list, dict]], prefix: list[str] = None) -> jsonpatch.JsonPatch:
126+
return jsonpatch.JsonPatch(
127+
[
128+
{
129+
"op": "copy",
130+
"path": self._format_path(self.path, prefix),
131+
"from": self._format_path(self.fromm, prefix),
132+
}
133+
]
134+
)
117135

118136

119137
class JsonPatchMove(JsonPatchOperator):
120138
def __init__(self, path: list[str], fromm: Any) -> None:
121139
super().__init__(path)
122140
self.fromm = fromm
123141

124-
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
125-
formatted_path = "/" + "/".join(self.path)
126-
formatted_from = "/" + "/".join(self.fromm)
127-
return jsonpatch.JsonPatch([{"op": "move", "path": formatted_path, "from": formatted_from}])
142+
def generate_patch(self, contexts: list[Union[list, dict]], prefix: list[str] = None) -> jsonpatch.JsonPatch:
143+
return jsonpatch.JsonPatch(
144+
[
145+
{
146+
"op": "move",
147+
"path": self._format_path(self.path, prefix),
148+
"from": self._format_path(self.fromm, prefix),
149+
}
150+
]
151+
)
128152

129153

130154
class JsonPatchTest(JsonPatchOperator):
131155
def __init__(self, path: list[str], value: Any) -> None:
132156
super().__init__(path)
133157
self.value = value
134158

135-
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
136-
formatted_path = "/" + "/".join(self.path)
137-
return jsonpatch.JsonPatch([{"op": "test", "path": formatted_path, "value": self.value}])
159+
def generate_patch(self, contexts: list[Union[list, dict]], prefix: list[str] = None) -> jsonpatch.JsonPatch:
160+
return jsonpatch.JsonPatch([{"op": "test", "path": self._format_path(self.path, prefix), "value": self.value}])
138161

139162

140163
class JsonPatchExpr(JsonPatchOperator):
@@ -147,7 +170,24 @@ def __init__(self, path: list[str], value: operators.Operator) -> None:
147170
super().__init__(path)
148171
self.value = value
149172

150-
def generate_patch(self, json_to_patch: dict | list) -> jsonpatch.JsonPatch:
151-
actual_value = self.value.get_value([json_to_patch])
173+
def generate_patch(self, contexts: list[Union[list, dict]], prefix: list[str] = None) -> jsonpatch.JsonPatch:
174+
actual_value = self.value.get_value(contexts)
152175
json_patch_add = JsonPatchAdd(self.path, actual_value)
153-
return json_patch_add.generate_patch(json_to_patch)
176+
return json_patch_add.generate_patch(contexts, prefix)
177+
178+
179+
class JsonPatchForEach(JsonPatchOperator):
180+
"""Generates a jsonpatch for each element from a list"""
181+
182+
def __init__(self, op_with_ref: operators.OperatorWithRef, list_jsonpatch_op: list[JsonPatchOperator]) -> None:
183+
super().__init__([])
184+
self.op_with_ref = op_with_ref
185+
self.list_jsonpatch_op = list_jsonpatch_op
186+
187+
def generate_patch(self, contexts: list[Union[list, dict]], prefix: list[str] = None) -> jsonpatch.JsonPatch:
188+
list_raw_patch = []
189+
for payload, path in self.op_with_ref.get_value_with_ref(contexts):
190+
for jsonpatch_op in self.list_jsonpatch_op:
191+
patch_obj = jsonpatch_op.generate_patch(contexts + [payload], path)
192+
list_raw_patch.extend(patch_obj.patch)
193+
return jsonpatch.JsonPatch(list_raw_patch)

generic_k8s_webhook/operators.py

+48-15
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,33 @@ def __init__(self, op_inputs: Any, path_op: str) -> None:
1515

1616
@abc.abstractmethod
1717
def input_type(self) -> type | None:
18-
pass
18+
"""Returns the expected type for the input parameters. This must match with
19+
the return type of the operators that generate input data for this one
20+
"""
1921

2022
@abc.abstractmethod
2123
def return_type(self) -> type | None:
22-
pass
24+
"""Returns the expected type for the return value of the `get_value` function"""
2325

2426
@abc.abstractmethod
25-
def get_value(self, contexts: list):
26-
pass
27+
def get_value(self, contexts: list) -> Any:
28+
"""Returns a value for this operator given a certain context
29+
30+
Args:
31+
contexts (list): It's the list of contexts (json payloads) used to evaluate this operator
32+
"""
33+
34+
35+
class OperatorWithRef(Operator):
36+
@abc.abstractmethod
37+
def get_value_with_ref(self, contexts: list) -> Any:
38+
"""Similar to `get_value`, but returns a tuple (or list of tuples) where the first element
39+
is the actual return value and the second one is a reference to the place in the context
40+
that was used to get this value
41+
42+
Args:
43+
contexts (list): It's the list of contexts (json payloads) used to evaluate this operator
44+
"""
2745

2846

2947
# It's the base class for operators like and, or, sum, etc.
@@ -344,41 +362,56 @@ def return_type(self) -> type | None:
344362
return type(self.value)
345363

346364

347-
class GetValue(Operator):
365+
class GetValue(OperatorWithRef):
348366
def __init__(self, path: list[str], context_id: int) -> None:
349367
self.path = path
350368
self.context_id = context_id
351369

352370
def get_value(self, contexts: list):
371+
values_with_ref = self.get_value_with_ref(contexts)
372+
if isinstance(values_with_ref, list):
373+
return [value for value, _ in values_with_ref]
374+
value, _ = values_with_ref
375+
return value
376+
377+
def get_value_with_ref(self, contexts: list):
353378
context = contexts[self.context_id]
354-
return self._get_value_from_json(context, self.path)
379+
return self._get_value_from_json(context, self.path, [])
355380

356-
def _get_value_from_json(self, data: Union[list, dict], path: list):
381+
def _get_value_from_json(
382+
self, data: Union[list, dict], path: list, formated_path: list
383+
) -> Union[tuple, list[tuple]]:
357384
if len(path) == 0 or path[0] == "":
358-
return data
385+
# It can return both a single data point or a list of elements
386+
# In the first case, we just return a tuple (data, path)
387+
# In the second case, we create a tuple for each element in the list
388+
# so we know the path of each element
389+
if isinstance(data, list):
390+
return [(elem, formated_path + [i]) for i, elem in enumerate(data)]
391+
return (data, formated_path)
359392

360393
if path[0] == "*":
361-
return self._evaluate_wildcard(data, path)
394+
return self._evaluate_wildcard(data, path, formated_path)
362395

363396
if isinstance(data, dict):
364397
key = path[0]
365398
if key in data:
366-
return self._get_value_from_json(data[key], path[1:])
399+
return self._get_value_from_json(data[key], path[1:], formated_path + [key])
367400
elif isinstance(data, list):
368401
key = int(path[0])
369402
if 0 <= key < len(data):
370-
return self._get_value_from_json(data[key], path[1:])
403+
return self._get_value_from_json(data[key], path[1:], formated_path + [key])
371404
else:
372405
raise RuntimeError(f"Expected list or dict, but got {data}")
373406

374-
return None
407+
return []
375408

376-
def _evaluate_wildcard(self, data: Union[list, dict], path: list):
409+
def _evaluate_wildcard(self, data: Union[list, dict], path: list, formated_path: list) -> list[tuple]:
377410
if not isinstance(data, list):
378411
raise RuntimeError(f"Expected list when evaluating '*', but got {data}")
379412
l = []
380-
for elem in data:
381-
sublist = self._get_value_from_json(elem, path[1:])
413+
for i, elem in enumerate(data):
414+
sublist = self._get_value_from_json(elem, path[1:], formated_path + [i])
382415
if isinstance(sublist, list):
383416
l.extend(sublist)
384417
else:

0 commit comments

Comments
 (0)