Skip to content

Commit 1a65b5f

Browse files
Ability to set multiple webhooks on the same path
If we define different webhooks with the same value on the "path" field, then these webhooks will be called in sequence when the server receives a request for the given path. The resulting "accept" responses will be ANDed and the patches will be concatenated. Notice that a given webhook will see the patches from the previous webhooks already applied to the object that it must inspect.
1 parent f81225f commit 1a65b5f

File tree

5 files changed

+112
-24
lines changed

5 files changed

+112
-24
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ actions:
9191
9292
```
9393

94+
If more than one webhook have the same path, they will be called in order. The `accept` responses are ANDed and the `patch` responses are concatenated. Notice that a given webhook will receive the payload already modified by all the previous webhooks that have the same path.
95+
9496
The syntax of the `condition` can be found in [Defining a condition](#defining-a-condition). The syntax of the patch can be found in [Defining a patch](#defining-a-patch).
9597

9698
### Testing the `GenericWebhookConfig` file is correct

generic_k8s_webhook/http_server.py

+32-19
Original file line numberDiff line numberDiff line change
@@ -79,28 +79,33 @@ def do_POST(self):
7979

8080
def _do_post(self):
8181
logging.info(f"Processing request from {self.address_string()}")
82-
request_served = False
83-
for webhook in self.CONFIG_LOADER.get_webhooks():
84-
if self._get_path() == webhook.path:
85-
content_length = int(self.headers["Content-Length"])
86-
raw_body = self.rfile.read(content_length)
87-
body = json.loads(raw_body)
88-
request = body["request"]
89-
90-
uid = request["uid"]
91-
accept, patch = webhook.process_manifest(request["object"])
92-
response = self._generate_response(uid, accept, patch)
93-
94-
self.send_response(200)
95-
self.end_headers()
96-
self.wfile.write(json.dumps(response).encode("utf-8"))
97-
98-
request_served = True
82+
webhook_paths = [webhook.path for webhook in self.CONFIG_LOADER.get_webhooks()]
9983

100-
if not request_served:
84+
# The path in the url is not defined in this server
85+
if self._get_path() not in webhook_paths:
10186
self.send_response(400)
10287
self.end_headers()
103-
logging.error(f"Wrong path {self.path}")
88+
logging.error(f"Wrong path {self.path} Not defined")
89+
return
90+
91+
request = self._get_body_request()
92+
uid = request["uid"]
93+
# Calling in order all the webhooks that have the target path. They all must set accept=True to
94+
# accept the request. The patches are concatenated and applied for the next call to "process_manifest"
95+
final_patch = jsonpatch.JsonPatch([])
96+
for webhook in self.CONFIG_LOADER.get_webhooks():
97+
if self._get_path() == webhook.path:
98+
# The call to the current webhook needs a json object that has been updated by the previous patches
99+
patched_object = final_patch.apply(request["object"])
100+
accept, patch = webhook.process_manifest(patched_object)
101+
final_patch = jsonpatch.JsonPatch(list(final_patch) + list(patch))
102+
if not accept:
103+
break
104+
105+
response = self._generate_response(uid, accept, final_patch)
106+
self.send_response(200)
107+
self.end_headers()
108+
self.wfile.write(json.dumps(response).encode("utf-8"))
104109

105110
def _generate_response(self, uid: str, accept: bool, patch: jsonpatch.JsonPatch) -> dict:
106111
response = {
@@ -122,6 +127,14 @@ def _get_path(self) -> str:
122127
parsed_url = urlparse(self.path)
123128
return parsed_url.path
124129

130+
def _get_body_request(self) -> dict:
131+
"""Returns the "request" field of the body of the current request"""
132+
content_length = int(self.headers["Content-Length"])
133+
raw_body = self.rfile.read(content_length)
134+
body = json.loads(raw_body)
135+
request = body["request"]
136+
return request
137+
125138

126139
class Server:
127140
def __init__( # pylint: disable=too-many-arguments

generic_k8s_webhook/webhook.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ def check_condition(self, manifest: dict) -> bool:
1414
return self.condition.get_value([manifest])
1515

1616
def get_patches(self, json_payload: dict) -> jsonpatch.JsonPatch | None:
17-
if not self.list_jpatch_op:
18-
return None
19-
2017
# 1. Generate a json patch specific for the json_payload
2118
# 2. Update the json_payload based on that patch
2219
# 3. Extract the raw patch, so we can merge later all the patches into a single JsonPatch object
@@ -42,4 +39,4 @@ def process_manifest(self, manifest: dict) -> tuple[bool, jsonpatch.JsonPatch |
4239
return action.accept, patches
4340

4441
# If no condition is met, we'll accept the manifest without any patch
45-
return True, None
42+
return True, jsonpatch.JsonPatch([])

tests/http_server_test.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
@pytest.mark.parametrize(
1919
("name_test", "req", "webhook_config", "expected_response"),
2020
load_test_case(os.path.join(HTTP_SERVER_TEST_DATA_DIR, "test_case_1.yaml"))
21-
+ load_test_case(os.path.join(HTTP_SERVER_TEST_DATA_DIR, "test_case_3.yaml")),
21+
+ load_test_case(os.path.join(HTTP_SERVER_TEST_DATA_DIR, "test_case_3.yaml"))
22+
+ load_test_case(os.path.join(HTTP_SERVER_TEST_DATA_DIR, "test_case_4.yaml")),
2223
)
2324
def test_http_server(name_test, req, webhook_config, expected_response, tmp_path):
2425
webhook_config_file = tmp_path / "webhook_config.yaml"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
request:
2+
path: /my-webhook
3+
body:
4+
apiVersion: admission.k8s.io/v1
5+
kind: AdmissionReview
6+
request:
7+
uid: "1234"
8+
object:
9+
apiVersion: v1
10+
kind: ServiceAccount
11+
metadata:
12+
name: experimental-descheduler
13+
namespace: kube-system
14+
labels:
15+
app.kubernetes.io/name: descheduler
16+
app.kubernetes.io/version: "0.27.1"
17+
18+
webhook_config:
19+
apiVersion: generic-webhook/v1beta1
20+
kind: GenericWebhookConfig
21+
webhooks:
22+
- name: my-webhook-1
23+
path: /my-webhook
24+
actions:
25+
# Refuse the request if the name is "random-name"
26+
- condition: .metadata.name == "random-name"
27+
accept: false
28+
# Otherwise, accept it and add a label
29+
- accept: true
30+
patch:
31+
- op: add
32+
path: .metadata.labels
33+
value:
34+
myLabel: myValue
35+
36+
- name: my-webhook-2
37+
path: /my-webhook
38+
actions:
39+
# Add another label if myLabel is present. This only happens if the previous
40+
# call to my-webhook-1 went through the second action
41+
- condition: .metadata.labels.myLabel == "myValue"
42+
patch:
43+
- op: add
44+
path: .metadata.labels
45+
value:
46+
otherLabel: otherValue
47+
48+
cases:
49+
- patches: []
50+
expected_response:
51+
apiVersion: admission.k8s.io/v1
52+
kind: AdmissionReview
53+
response:
54+
uid: "1234"
55+
allowed: True
56+
patchType: JSONPatch
57+
patch:
58+
- op: add
59+
path: /metadata/labels
60+
value:
61+
myLabel: myValue
62+
- op: add
63+
path: /metadata/labels
64+
value:
65+
otherLabel: otherValue
66+
67+
- patches:
68+
- key: [request, body, request, object, metadata, name]
69+
value: random-name
70+
expected_response:
71+
apiVersion: admission.k8s.io/v1
72+
kind: AdmissionReview
73+
response:
74+
uid: "1234"
75+
allowed: False

0 commit comments

Comments
 (0)