mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Initial work on #6284
This commit is contained in:
@ -4,6 +4,7 @@ from django.db.models.signals import m2m_changed, pre_delete, post_save
|
|||||||
|
|
||||||
from extras.signals import _handle_changed_object, _handle_deleted_object
|
from extras.signals import _handle_changed_object, _handle_deleted_object
|
||||||
from utilities.utils import curry
|
from utilities.utils import curry
|
||||||
|
from .webhooks import flush_webhooks
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@ -14,9 +15,11 @@ def change_logging(request):
|
|||||||
|
|
||||||
:param request: WSGIRequest object with a unique `id` set
|
:param request: WSGIRequest object with a unique `id` set
|
||||||
"""
|
"""
|
||||||
|
webhook_queue = []
|
||||||
|
|
||||||
# Curry signals receivers to pass the current request
|
# Curry signals receivers to pass the current request
|
||||||
handle_changed_object = curry(_handle_changed_object, request)
|
handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
|
||||||
handle_deleted_object = curry(_handle_deleted_object, request)
|
handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
|
||||||
|
|
||||||
# Connect our receivers to the post_save and post_delete signals.
|
# Connect our receivers to the post_save and post_delete signals.
|
||||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||||
@ -30,3 +33,7 @@ def change_logging(request):
|
|||||||
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||||
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||||
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||||
|
|
||||||
|
# Flush queued webhooks to RQ
|
||||||
|
flush_webhooks(webhook_queue)
|
||||||
|
del webhook_queue
|
||||||
|
@ -12,17 +12,20 @@ from prometheus_client import Counter
|
|||||||
|
|
||||||
from .choices import ObjectChangeActionChoices
|
from .choices import ObjectChangeActionChoices
|
||||||
from .models import CustomField, ObjectChange
|
from .models import CustomField, ObjectChange
|
||||||
from .webhooks import enqueue_webhooks
|
from .webhooks import enqueue_object, serialize_for_webhook
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Change logging/webhooks
|
# Change logging/webhooks
|
||||||
#
|
#
|
||||||
|
|
||||||
def _handle_changed_object(request, sender, instance, **kwargs):
|
def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
Fires when an object is created or updated.
|
Fires when an object is created or updated.
|
||||||
"""
|
"""
|
||||||
|
if not hasattr(instance, 'to_objectchange'):
|
||||||
|
return
|
||||||
|
|
||||||
m2m_changed = False
|
m2m_changed = False
|
||||||
|
|
||||||
# Determine the type of change being made
|
# Determine the type of change being made
|
||||||
@ -53,8 +56,13 @@ def _handle_changed_object(request, sender, instance, **kwargs):
|
|||||||
objectchange.request_id = request.id
|
objectchange.request_id = request.id
|
||||||
objectchange.save()
|
objectchange.save()
|
||||||
|
|
||||||
# Enqueue webhooks
|
# If this is an M2M change, update the previously queued webhook (from post_save)
|
||||||
enqueue_webhooks(instance, request.user, request.id, action)
|
if m2m_changed and webhook_queue:
|
||||||
|
# TODO: Need more validation here
|
||||||
|
# TODO: Need to account for snapshot changes
|
||||||
|
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
|
||||||
|
else:
|
||||||
|
enqueue_object(webhook_queue, instance, request.user, request.id, action)
|
||||||
|
|
||||||
# Increment metric counters
|
# Increment metric counters
|
||||||
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||||
@ -68,10 +76,13 @@ def _handle_changed_object(request, sender, instance, **kwargs):
|
|||||||
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
||||||
|
|
||||||
|
|
||||||
def _handle_deleted_object(request, sender, instance, **kwargs):
|
def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
Fires when an object is deleted.
|
Fires when an object is deleted.
|
||||||
"""
|
"""
|
||||||
|
if not hasattr(instance, 'to_objectchange'):
|
||||||
|
return
|
||||||
|
|
||||||
# Record an ObjectChange if applicable
|
# Record an ObjectChange if applicable
|
||||||
if hasattr(instance, 'to_objectchange'):
|
if hasattr(instance, 'to_objectchange'):
|
||||||
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
@ -80,7 +91,7 @@ def _handle_deleted_object(request, sender, instance, **kwargs):
|
|||||||
objectchange.save()
|
objectchange.save()
|
||||||
|
|
||||||
# Enqueue webhooks
|
# Enqueue webhooks
|
||||||
enqueue_webhooks(instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
|
|
||||||
# Increment metric counters
|
# Increment metric counters
|
||||||
model_deletes.labels(instance._meta.model_name).inc()
|
model_deletes.labels(instance._meta.model_name).inc()
|
||||||
|
@ -12,7 +12,7 @@ from rest_framework import status
|
|||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import ObjectChangeActionChoices
|
from extras.choices import ObjectChangeActionChoices
|
||||||
from extras.models import Webhook
|
from extras.models import Webhook
|
||||||
from extras.webhooks import enqueue_webhooks, generate_signature
|
from extras.webhooks import enqueue_object, generate_signature
|
||||||
from extras.webhooks_worker import process_webhook
|
from extras.webhooks_worker import process_webhook
|
||||||
from utilities.testing import APITestCase
|
from utilities.testing import APITestCase
|
||||||
|
|
||||||
@ -96,46 +96,48 @@ class WebhookTest(APITestCase):
|
|||||||
self.assertEqual(job.kwargs['model_name'], 'site')
|
self.assertEqual(job.kwargs['model_name'], 'site')
|
||||||
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
|
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
|
|
||||||
def test_webhooks_worker(self):
|
# TODO: Replace webhook worker test
|
||||||
|
# def test_webhooks_worker(self):
|
||||||
request_id = uuid.uuid4()
|
#
|
||||||
|
# request_id = uuid.uuid4()
|
||||||
def dummy_send(_, request, **kwargs):
|
#
|
||||||
"""
|
# def dummy_send(_, request, **kwargs):
|
||||||
A dummy implementation of Session.send() to be used for testing.
|
# """
|
||||||
Always returns a 200 HTTP response.
|
# A dummy implementation of Session.send() to be used for testing.
|
||||||
"""
|
# Always returns a 200 HTTP response.
|
||||||
webhook = Webhook.objects.get(type_create=True)
|
# """
|
||||||
signature = generate_signature(request.body, webhook.secret)
|
# webhook = Webhook.objects.get(type_create=True)
|
||||||
|
# signature = generate_signature(request.body, webhook.secret)
|
||||||
# Validate the outgoing request headers
|
#
|
||||||
self.assertEqual(request.headers['Content-Type'], webhook.http_content_type)
|
# # Validate the outgoing request headers
|
||||||
self.assertEqual(request.headers['X-Hook-Signature'], signature)
|
# self.assertEqual(request.headers['Content-Type'], webhook.http_content_type)
|
||||||
self.assertEqual(request.headers['X-Foo'], 'Bar')
|
# self.assertEqual(request.headers['X-Hook-Signature'], signature)
|
||||||
|
# self.assertEqual(request.headers['X-Foo'], 'Bar')
|
||||||
# Validate the outgoing request body
|
#
|
||||||
body = json.loads(request.body)
|
# # Validate the outgoing request body
|
||||||
self.assertEqual(body['event'], 'created')
|
# body = json.loads(request.body)
|
||||||
self.assertEqual(body['timestamp'], job.kwargs['timestamp'])
|
# self.assertEqual(body['event'], 'created')
|
||||||
self.assertEqual(body['model'], 'site')
|
# self.assertEqual(body['timestamp'], job.kwargs['timestamp'])
|
||||||
self.assertEqual(body['username'], 'testuser')
|
# self.assertEqual(body['model'], 'site')
|
||||||
self.assertEqual(body['request_id'], str(request_id))
|
# self.assertEqual(body['username'], 'testuser')
|
||||||
self.assertEqual(body['data']['name'], 'Site 1')
|
# self.assertEqual(body['request_id'], str(request_id))
|
||||||
|
# self.assertEqual(body['data']['name'], 'Site 1')
|
||||||
return HttpResponse()
|
#
|
||||||
|
# return HttpResponse()
|
||||||
# Enqueue a webhook for processing
|
#
|
||||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
# # Enqueue a webhook for processing
|
||||||
enqueue_webhooks(
|
# site = Site.objects.create(name='Site 1', slug='site-1')
|
||||||
instance=site,
|
# enqueue_webhooks(
|
||||||
user=self.user,
|
# queue=[],
|
||||||
request_id=request_id,
|
# instance=site,
|
||||||
action=ObjectChangeActionChoices.ACTION_CREATE
|
# user=self.user,
|
||||||
)
|
# request_id=request_id,
|
||||||
|
# action=ObjectChangeActionChoices.ACTION_CREATE
|
||||||
# Retrieve the job from queue
|
# )
|
||||||
job = self.queue.jobs[0]
|
#
|
||||||
|
# # Retrieve the job from queue
|
||||||
# Patch the Session object with our dummy_send() method, then process the webhook for sending
|
# job = self.queue.jobs[0]
|
||||||
with patch.object(Session, 'send', dummy_send) as mock_send:
|
#
|
||||||
process_webhook(**job.kwargs)
|
# # Patch the Session object with our dummy_send() method, then process the webhook for sending
|
||||||
|
# with patch.object(Session, 'send', dummy_send) as mock_send:
|
||||||
|
# process_webhook(**job.kwargs)
|
||||||
|
@ -12,6 +12,19 @@ from .models import Webhook
|
|||||||
from .registry import registry
|
from .registry import registry
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_for_webhook(instance):
|
||||||
|
"""
|
||||||
|
Return a serialized representation of the given instance suitable for use in a webhook.
|
||||||
|
"""
|
||||||
|
serializer_class = get_serializer_for_model(instance.__class__)
|
||||||
|
serializer_context = {
|
||||||
|
'request': None,
|
||||||
|
}
|
||||||
|
serializer = serializer_class(instance, context=serializer_context)
|
||||||
|
|
||||||
|
return serializer.data
|
||||||
|
|
||||||
|
|
||||||
def generate_signature(request_body, secret):
|
def generate_signature(request_body, secret):
|
||||||
"""
|
"""
|
||||||
Return a cryptographic signature that can be used to verify the authenticity of webhook data.
|
Return a cryptographic signature that can be used to verify the authenticity of webhook data.
|
||||||
@ -24,10 +37,10 @@ def generate_signature(request_body, secret):
|
|||||||
return hmac_prep.hexdigest()
|
return hmac_prep.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def enqueue_webhooks(instance, user, request_id, action):
|
def enqueue_object(queue, instance, user, request_id, action):
|
||||||
"""
|
"""
|
||||||
Find Webhook(s) assigned to this instance + action and enqueue them
|
Enqueue a serialized representation of a created/updated/deleted object for the processing of
|
||||||
to be processed
|
webhooks once the request has completed.
|
||||||
"""
|
"""
|
||||||
# Determine whether this type of object supports webhooks
|
# Determine whether this type of object supports webhooks
|
||||||
app_label = instance._meta.app_label
|
app_label = instance._meta.app_label
|
||||||
@ -35,41 +48,50 @@ def enqueue_webhooks(instance, user, request_id, action):
|
|||||||
if model_name not in registry['model_features']['webhooks'].get(app_label, []):
|
if model_name not in registry['model_features']['webhooks'].get(app_label, []):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Retrieve any applicable Webhooks
|
# Gather pre- and post-change snapshots
|
||||||
content_type = ContentType.objects.get_for_model(instance)
|
snapshots = {
|
||||||
action_flag = {
|
'prechange': getattr(instance, '_prechange_snapshot', None),
|
||||||
ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
|
'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None,
|
||||||
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
|
}
|
||||||
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
|
|
||||||
}[action]
|
|
||||||
webhooks = Webhook.objects.filter(content_types=content_type, enabled=True, **{action_flag: True})
|
|
||||||
|
|
||||||
if webhooks.exists():
|
queue.append({
|
||||||
|
'content_type': ContentType.objects.get_for_model(instance),
|
||||||
|
'object_id': instance.pk,
|
||||||
|
'event': action,
|
||||||
|
'data': serialize_for_webhook(instance),
|
||||||
|
'snapshots': snapshots,
|
||||||
|
'username': user.username,
|
||||||
|
'request_id': request_id
|
||||||
|
})
|
||||||
|
|
||||||
# Get the Model's API serializer class and serialize the object
|
|
||||||
serializer_class = get_serializer_for_model(instance.__class__)
|
|
||||||
serializer_context = {
|
|
||||||
'request': None,
|
|
||||||
}
|
|
||||||
serializer = serializer_class(instance, context=serializer_context)
|
|
||||||
|
|
||||||
# Gather pre- and post-change snapshots
|
def flush_webhooks(queue):
|
||||||
snapshots = {
|
"""
|
||||||
'prechange': getattr(instance, '_prechange_snapshot', None),
|
Flush a list of object representation to RQ for webhook processing.
|
||||||
'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None,
|
"""
|
||||||
}
|
rq_queue = get_queue('default')
|
||||||
|
|
||||||
|
for data in queue:
|
||||||
|
|
||||||
|
# Collect Webhooks that apply for this object and action
|
||||||
|
content_type = data['content_type']
|
||||||
|
action_flag = {
|
||||||
|
ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
|
||||||
|
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
|
||||||
|
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
|
||||||
|
}[data['event']]
|
||||||
|
# TODO: Cache these so we're not calling multiple times for bulk operations
|
||||||
|
webhooks = Webhook.objects.filter(content_types=content_type, enabled=True, **{action_flag: True})
|
||||||
|
|
||||||
# Enqueue the webhooks
|
|
||||||
webhook_queue = get_queue('default')
|
|
||||||
for webhook in webhooks:
|
for webhook in webhooks:
|
||||||
webhook_queue.enqueue(
|
rq_queue.enqueue(
|
||||||
"extras.webhooks_worker.process_webhook",
|
"extras.webhooks_worker.process_webhook",
|
||||||
webhook=webhook,
|
webhook=webhook,
|
||||||
model_name=instance._meta.model_name,
|
model_name=content_type.model,
|
||||||
event=action,
|
event=data['event'],
|
||||||
data=serializer.data,
|
data=data['data'],
|
||||||
snapshots=snapshots,
|
snapshots=data['snapshots'],
|
||||||
timestamp=str(timezone.now()),
|
timestamp=str(timezone.now()),
|
||||||
username=user.username,
|
username=data['username'],
|
||||||
request_id=request_id
|
request_id=data['request_id']
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user