Skip to content
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
41 changes: 40 additions & 1 deletion awx/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1499,6 +1499,13 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
status = serializers.ChoiceField(choices=Project.PROJECT_STATUS_CHOICES, read_only=True)
last_update_failed = serializers.BooleanField(read_only=True)
last_updated = serializers.DateTimeField(read_only=True)
webhook_key = serializers.CharField(
write_only=True,
required=False,
allow_blank=True,
max_length=64,
help_text=_('Shared secret that the webhook service will use to sign requests. Leave blank to generate a new one when the webhook service is set.'),
)
show_capabilities = ['start', 'schedule', 'edit', 'delete', 'copy']
capabilities_prefetch = ['admin', 'update', {'copy': 'organization.project_admin'}]

Expand All @@ -1513,6 +1520,9 @@ class Meta:
'allow_override',
'default_environment',
'signature_validation_credential',
'webhook_service',
'webhook_key',
'webhook_ref_filter',
) + (
'last_update_failed',
'last_updated',
Expand All @@ -1537,6 +1547,12 @@ def get_related(self, obj):
access_list=self.reverse('api:project_access_list', kwargs={'pk': obj.pk}),
object_roles=self.reverse('api:project_object_roles_list', kwargs={'pk': obj.pk}),
copy=self.reverse('api:project_copy', kwargs={'pk': obj.pk}),
webhook_key=self.reverse('api:webhook_key', kwargs={'model_kwarg': 'projects', 'pk': obj.pk}),
webhook_receiver=(
self.reverse('api:webhook_receiver_{}'.format(obj.webhook_service), kwargs={'model_kwarg': 'projects', 'pk': obj.pk})
if obj.webhook_service
else ''
),
)
)
if obj.organization:
Expand Down Expand Up @@ -1581,6 +1597,10 @@ def get_field_from_model_or_attrs(fd):
for fd in ('scm_update_on_launch', 'scm_delete_on_update', 'scm_track_submodules', 'scm_clean'):
if get_field_from_model_or_attrs(fd):
raise serializers.ValidationError({fd: _('Update options must be set to false for manual projects.')})
if get_field_from_model_or_attrs('webhook_service') and not get_field_from_model_or_attrs('scm_type'):
raise serializers.ValidationError({'webhook_service': _('Webhooks are not supported for manual projects.')})
if attrs.get('webhook_key') and not get_field_from_model_or_attrs('webhook_service'):
raise serializers.ValidationError({'webhook_key': _("Cannot set a webhook key without a webhook service.")})
return super(ProjectSerializer, self).validate(attrs)


Expand Down Expand Up @@ -1625,7 +1645,7 @@ class Meta:
class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer):
class Meta:
model = ProjectUpdate
fields = ('*', 'project', 'job_type', 'job_tags', '-controller_node')
fields = ('*', 'project', 'job_type', 'job_tags', '-controller_node', 'webhook_service', 'webhook_guid')

def get_related(self, obj):
res = super(ProjectUpdateSerializer, self).get_related(obj)
Expand Down Expand Up @@ -3285,6 +3305,9 @@ def validate(self, attrs):
webhook_service = attrs.get('webhook_service', getattr(self.instance, 'webhook_service', None))
webhook_credential = attrs.get('webhook_credential', getattr(self.instance, 'webhook_credential', None))

if attrs.get('webhook_key') and not webhook_service:
raise serializers.ValidationError({'webhook_key': _("Cannot set a webhook key without a webhook service.")})

if webhook_credential:
if webhook_credential.credential_type.kind != 'token':
raise serializers.ValidationError({'webhook_credential': _("Must be a Personal Access Token.")})
Expand All @@ -3304,6 +3327,13 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
capabilities_prefetch = ['admin', 'execute', {'copy': ['project.use', 'inventory.use']}]

status = serializers.ChoiceField(choices=JobTemplate.JOB_TEMPLATE_STATUS_CHOICES, read_only=True, required=False)
webhook_key = serializers.CharField(
write_only=True,
required=False,
allow_blank=True,
max_length=64,
help_text=_('Shared secret that the webhook service will use to sign requests. Leave blank to generate a new one when the webhook service is set.'),
)

class Meta:
model = JobTemplate
Expand Down Expand Up @@ -3333,6 +3363,7 @@ class Meta:
'job_slice_count',
'webhook_service',
'webhook_credential',
'webhook_key',
'prevent_instance_group_fallback',
)
read_only_fields = ('*',)
Expand Down Expand Up @@ -3787,6 +3818,13 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo

skip_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
job_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
webhook_key = serializers.CharField(
write_only=True,
required=False,
allow_blank=True,
max_length=64,
help_text=_('Shared secret that the webhook service will use to sign requests. Leave blank to generate a new one when the webhook service is set.'),
)

class Meta:
model = WorkflowJobTemplate
Expand All @@ -3805,6 +3843,7 @@ class Meta:
'ask_limit_on_launch',
'webhook_service',
'webhook_credential',
'webhook_key',
'-execution_environment',
'ask_labels_on_launch',
'ask_skip_tags_on_launch',
Expand Down
9 changes: 6 additions & 3 deletions awx/api/templates/api/webhook_key_view.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
Webhook Secret Key:

Make a GET request to this resource to obtain the secret key for a job
template or workflow job template configured to be triggered by
webhook events. The response will include the following fields:
template, workflow job template or project configured to be triggered
by webhook events. The response will include the following fields:

* `webhook_key`: Secret key that needs to be copied and added to the
webhook configuration of the service this template will be receiving
webhook configuration of the service this resource will be receiving
webhook events from (string, read-only)

Make an empty POST request to this resource to generate a new
replacement `webhook_key`.

A specific key can also be set by writing to the `webhook_key` field
of the job template, workflow job template or project resource itself.
3 changes: 2 additions & 1 deletion awx/api/urls/project.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright (c) 2017 Ansible, Inc.
# All Rights Reserved.

from django.urls import path
from django.urls import include, path

from awx.api.views import (
ProjectList,
Expand Down Expand Up @@ -47,6 +47,7 @@
path('<int:pk>/object_roles/', ProjectObjectRolesList.as_view(), name='project_object_roles_list'),
path('<int:pk>/access_list/', ProjectAccessList.as_view(), name='project_access_list'),
path('<int:pk>/copy/', ProjectCopy.as_view(), name='project_copy'),
path('<int:pk>/', include('awx.api.urls.webhooks'), {'model_kwarg': 'projects'}),
]

__all__ = ['urls']
61 changes: 56 additions & 5 deletions awx/api/views/webhooks.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from fnmatch import fnmatchcase
from hashlib import sha1, sha256
import hmac
import logging
Expand All @@ -15,7 +16,7 @@
from awx.api import serializers
from awx.api.generics import APIView, GenericAPIView
from awx.api.permissions import WebhookKeyPermission
from awx.main.models import Job, JobTemplate, WorkflowJob, WorkflowJobTemplate
from awx.main.models import Job, JobTemplate, Project, ProjectUpdate, WorkflowJob, WorkflowJobTemplate
from awx.main.constants import JOB_VARIABLE_PREFIXES

logger = logging.getLogger('awx.api.views.webhooks')
Expand All @@ -26,7 +27,7 @@ class WebhookKeyView(GenericAPIView):
permission_classes = (WebhookKeyPermission,)

def get_queryset(self):
qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate}
qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate, 'projects': Project}
self.model = qs_models.get(self.kwargs['model_kwarg'])

return super().get_queryset()
Expand All @@ -52,9 +53,11 @@ class WebhookReceiverBase(APIView):
authentication_classes = ()

ref_keys = {}
ref_name_keys = {}
project_sync_events = []

def get_queryset(self):
qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate}
qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate, 'projects': Project}
model = qs_models.get(self.kwargs['model_kwarg'])
if model is None:
raise PermissionDenied
Expand All @@ -81,8 +84,7 @@ def get_event_guid(self):
def get_event_status_api(self):
raise NotImplementedError

def get_event_ref(self):
key = self.ref_keys.get(self.get_event_type(), '')
def _get_payload_value(self, key):
value = self.request.data
for element in key.split('.'):
try:
Expand All @@ -92,10 +94,19 @@ def get_event_ref(self):
value = (value or {}).get(element)
except Exception:
value = None
return value

def get_event_ref(self):
value = self._get_payload_value(self.ref_keys.get(self.get_event_type(), ''))
if value == '0000000000000000000000000000000000000000': # a deleted ref
value = None
return value

def get_event_ref_name(self):
# The symbolic name of the pushed ref (e.g. refs/heads/main), as opposed
# to the commit hash returned by get_event_ref().
return self._get_payload_value(self.ref_name_keys.get(self.get_event_type(), ''))

def get_signature(self):
raise NotImplementedError

Expand Down Expand Up @@ -139,6 +150,9 @@ def post(self, request, *args, **kwargs_in):
# This was an ignored request type (e.g. ping), don't act on it
return Response({'message': _("Webhook ignored")}, status=status.HTTP_200_OK)

if isinstance(obj, Project):
return self.handle_project_sync(obj)

event_type = self.get_event_type()
event_guid = self.get_event_guid()
event_ref = self.get_event_ref()
Expand Down Expand Up @@ -172,6 +186,34 @@ def post(self, request, *args, **kwargs_in):

return Response({'message': "Job queued."}, status=status.HTTP_202_ACCEPTED)

def handle_project_sync(self, obj):
# For projects the webhook does not carry any variables into a playbook,
# it just triggers the same update the Sync button does, so the project
# is fetched with its configured branch/refspec.
if self.get_event_type() not in self.project_sync_events:
logger.debug("Webhook event type '{}' does not trigger a project sync, ignoring.".format(self.get_event_type()))
return Response({'message': _("Webhook ignored")}, status=status.HTTP_200_OK)

ref_name = self.get_event_ref_name()
if obj.webhook_ref_filter and not fnmatchcase(ref_name or '', obj.webhook_ref_filter):
logger.debug("Webhook ref '{}' did not match the ref filter of project {}, ignoring.".format(ref_name, obj.id))
return Response({'message': _("Webhook ref did not match the configured filter, ignoring.")}, status=status.HTTP_200_OK)

event_guid = self.get_event_guid()
kwargs = {'project_id': obj.id, 'webhook_service': obj.webhook_service, 'webhook_guid': event_guid}
if ProjectUpdate.objects.filter(**kwargs).exists():
# Short circuit if this webhook has already been received and acted upon.
logger.debug("Webhook previously received, returning without action.")
return Response({'message': _("Webhook previously received, aborting.")}, status=status.HTTP_202_ACCEPTED)
Comment thread
cigamit marked this conversation as resolved.

if not obj.can_update:
return Response({'message': _("Project cannot be updated.")}, status=status.HTTP_405_METHOD_NOT_ALLOWED)

project_update = obj.create_project_update(_eager_fields={'launch_type': 'webhook', 'webhook_service': obj.webhook_service, 'webhook_guid': event_guid})
project_update.signal_start()

return Response({'message': "Project update queued."}, status=status.HTTP_202_ACCEPTED)


class GithubWebhookReceiver(WebhookReceiverBase):
service = 'github'
Expand All @@ -187,6 +229,9 @@ class GithubWebhookReceiver(WebhookReceiverBase):
'page_build': 'build.commit',
}

ref_name_keys = {'push': 'ref'}
project_sync_events = ['push']

def get_event_type(self):
return self.request.headers.get('x-github-event')

Expand Down Expand Up @@ -215,6 +260,9 @@ class GitlabWebhookReceiver(WebhookReceiverBase):

ref_keys = {'Push Hook': 'checkout_sha', 'Tag Push Hook': 'checkout_sha', 'Merge Request Hook': 'object_attributes.last_commit.id'}

ref_name_keys = {'Push Hook': 'ref', 'Tag Push Hook': 'ref'}
project_sync_events = ['Push Hook', 'Tag Push Hook']

def get_event_type(self):
return self.request.headers.get('x-gitlab-event')

Expand Down Expand Up @@ -259,6 +307,9 @@ class BitbucketDcWebhookReceiver(WebhookReceiverBase):
'pr:modified': 'pullRequest.toRef.latestCommit',
}

ref_name_keys = {'repo:refs_changed': 'changes.0.ref.id', 'mirror:repo_synchronized': 'changes.0.ref.id'}
project_sync_events = ['repo:refs_changed', 'mirror:repo_synchronized']

def get_event_type(self):
return self.request.headers.get('x-event-key')

Expand Down
53 changes: 53 additions & 0 deletions awx/main/migrations/0202_project_webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Generated by Django 5.2.15 on 2026-07-04 10:01

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('main', '0201_workflowjobnodeconditionlink_and_more'),
]

operations = [
migrations.AddField(
model_name='project',
name='webhook_key',
field=models.CharField(blank=True, help_text='Shared secret that the webhook service will use to sign requests', max_length=64),
),
migrations.AddField(
model_name='project',
name='webhook_ref_filter',
field=models.CharField(
blank=True,
default='',
help_text='Only sync the project when the webhook ref matches this fnmatch pattern (e.g. refs/heads/main or refs/heads/release-*). When empty, any push or tag event triggers a sync.',
max_length=1024,
),
),
migrations.AddField(
model_name='project',
name='webhook_service',
field=models.CharField(
blank=True,
choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')],
help_text='Service that webhook requests will be accepted from',
max_length=16,
),
),
migrations.AddField(
model_name='projectupdate',
name='webhook_guid',
field=models.CharField(blank=True, help_text='Unique identifier of the event that triggered this webhook', max_length=128),
),
migrations.AddField(
model_name='projectupdate',
name='webhook_service',
field=models.CharField(
blank=True,
choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')],
help_text='Service that the webhook request that triggered this update came from',
max_length=16,
),
),
]
2 changes: 1 addition & 1 deletion awx/main/models/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
"""

FIELDS_TO_PRESERVE_AT_COPY = ['labels', 'instance_groups', 'credentials', 'survey_spec', 'prevent_instance_group_fallback']
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential']
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential', 'webhook_key']
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]

class Meta:
Expand Down
Loading