Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to set multiple webhooks on the same path #42

Merged
merged 1 commit into from
Aug 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ actions:

```

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.

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).

### Testing the `GenericWebhookConfig` file is correct
Expand Down
51 changes: 32 additions & 19 deletions generic_k8s_webhook/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,28 +79,33 @@ def do_POST(self):

def _do_post(self):
logging.info(f"Processing request from {self.address_string()}")
request_served = False
for webhook in self.CONFIG_LOADER.get_webhooks():
if self._get_path() == webhook.path:
content_length = int(self.headers["Content-Length"])
raw_body = self.rfile.read(content_length)
body = json.loads(raw_body)
request = body["request"]

uid = request["uid"]
accept, patch = webhook.process_manifest(request["object"])
response = self._generate_response(uid, accept, patch)

self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(response).encode("utf-8"))

request_served = True
webhook_paths = [webhook.path for webhook in self.CONFIG_LOADER.get_webhooks()]

if not request_served:
# The path in the url is not defined in this server
if self._get_path() not in webhook_paths:
self.send_response(400)
self.end_headers()
logging.error(f"Wrong path {self.path}")
logging.error(f"Wrong path {self.path} Not defined")
return

request = self._get_body_request()
uid = request["uid"]
# Calling in order all the webhooks that have the target path. They all must set accept=True to
# accept the request. The patches are concatenated and applied for the next call to "process_manifest"
final_patch = jsonpatch.JsonPatch([])
for webhook in self.CONFIG_LOADER.get_webhooks():
if self._get_path() == webhook.path:
# The call to the current webhook needs a json object that has been updated by the previous patches
patched_object = final_patch.apply(request["object"])
accept, patch = webhook.process_manifest(patched_object)
final_patch = jsonpatch.JsonPatch(list(final_patch) + list(patch))
if not accept:
break

response = self._generate_response(uid, accept, final_patch)
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(response).encode("utf-8"))

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

def _get_body_request(self) -> dict:
"""Returns the "request" field of the body of the current request"""
content_length = int(self.headers["Content-Length"])
raw_body = self.rfile.read(content_length)
body = json.loads(raw_body)
request = body["request"]
return request


class Server:
def __init__( # pylint: disable=too-many-arguments
Expand Down
5 changes: 1 addition & 4 deletions generic_k8s_webhook/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ def check_condition(self, manifest: dict) -> bool:
return self.condition.get_value([manifest])

def get_patches(self, json_payload: dict) -> jsonpatch.JsonPatch | None:
if not self.list_jpatch_op:
return None

# 1. Generate a json patch specific for the json_payload
# 2. Update the json_payload based on that patch
# 3. Extract the raw patch, so we can merge later all the patches into a single JsonPatch object
Expand All @@ -42,4 +39,4 @@ def process_manifest(self, manifest: dict) -> tuple[bool, jsonpatch.JsonPatch |
return action.accept, patches

# If no condition is met, we'll accept the manifest without any patch
return True, None
return True, jsonpatch.JsonPatch([])
3 changes: 2 additions & 1 deletion tests/http_server_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
@pytest.mark.parametrize(
("name_test", "req", "webhook_config", "expected_response"),
load_test_case(os.path.join(HTTP_SERVER_TEST_DATA_DIR, "test_case_1.yaml"))
+ load_test_case(os.path.join(HTTP_SERVER_TEST_DATA_DIR, "test_case_3.yaml")),
+ load_test_case(os.path.join(HTTP_SERVER_TEST_DATA_DIR, "test_case_3.yaml"))
+ load_test_case(os.path.join(HTTP_SERVER_TEST_DATA_DIR, "test_case_4.yaml")),
)
def test_http_server(name_test, req, webhook_config, expected_response, tmp_path):
webhook_config_file = tmp_path / "webhook_config.yaml"
Expand Down
75 changes: 75 additions & 0 deletions tests/http_server_test_data/test_case_4.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
request:
path: /my-webhook
body:
apiVersion: admission.k8s.io/v1
kind: AdmissionReview
request:
uid: "1234"
object:
apiVersion: v1
kind: ServiceAccount
metadata:
name: experimental-descheduler
namespace: kube-system
labels:
app.kubernetes.io/name: descheduler
app.kubernetes.io/version: "0.27.1"

webhook_config:
apiVersion: generic-webhook/v1beta1
kind: GenericWebhookConfig
webhooks:
- name: my-webhook-1
path: /my-webhook
actions:
# Refuse the request if the name is "random-name"
- condition: .metadata.name == "random-name"
accept: false
# Otherwise, accept it and add a label
- accept: true
patch:
- op: add
path: .metadata.labels
value:
myLabel: myValue

- name: my-webhook-2
path: /my-webhook
actions:
# Add another label if myLabel is present. This only happens if the previous
# call to my-webhook-1 went through the second action
- condition: .metadata.labels.myLabel == "myValue"
patch:
- op: add
path: .metadata.labels
value:
otherLabel: otherValue

cases:
- patches: []
expected_response:
apiVersion: admission.k8s.io/v1
kind: AdmissionReview
response:
uid: "1234"
allowed: True
patchType: JSONPatch
patch:
- op: add
path: /metadata/labels
value:
myLabel: myValue
- op: add
path: /metadata/labels
value:
otherLabel: otherValue

- patches:
- key: [request, body, request, object, metadata, name]
value: random-name
expected_response:
apiVersion: admission.k8s.io/v1
kind: AdmissionReview
response:
uid: "1234"
allowed: False
Loading